12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -17,7 +17,7 @@
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
border-color: var(--color-zinc-200, currentColor);
}
body {
@ -77,9 +77,83 @@
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
}
/* Card Styles */
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white shadow-md transition-all duration-300 hover:shadow-lg;
background-image: linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)));
}
.btn-primary:hover {
background-image: linear-gradient(
to right,
hsl(var(--primary) / 0.9),
hsl(var(--secondary) / 0.9)
);
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 transition-all duration-200 hover:bg-zinc-50 dark:hover:bg-zinc-700;
}
/* Card Styles - Base */
.card {
@apply bg-white rounded-lg shadow-md overflow-hidden;
@apply rounded-xl border transition-all duration-300 bg-white dark:bg-zinc-900;
border-color: hsl(var(--border));
box-shadow: var(--shadow-card);
}
.dark .card {
border-color: hsl(var(--border) / 0.1);
box-shadow: var(--shadow-card);
}
.card:hover {
box-shadow: var(--shadow-card-hover);
}
.card-hover {
@apply hover:scale-[1.02];
}
.card-hover:hover {
border-color: hsl(var(--primary) / 0.3);
}
/* Shadow utilities */
.shadow-card {
box-shadow: var(--shadow-card);
}
.shadow-card-hover {
box-shadow: var(--shadow-card-hover);
}
/* Badge Styles - Base */
.badge {
@apply inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200;
}
.badge-primary {
@apply border;
background-color: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.2);
}
.badge-secondary {
@apply border text-zinc-700 dark:text-zinc-300;
background-color: hsl(var(--secondary) / 0.1);
border-color: hsl(var(--secondary) / 0.2);
}
/* Input Styles */
.input-primary {
@apply w-full rounded-lg border px-4 py-2 text-sm transition-all duration-200 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100;
border-color: hsl(var(--input));
}
.input-primary:focus {
@apply outline-none;
--tw-ring-color: hsl(var(--primary));
box-shadow: 0 0 0 2px var(--tw-ring-color);
border-color: transparent;
}
/* Container */
@ -91,12 +165,645 @@
.section {
@apply py-12 md:py-16 lg:py-20;
}
/* Section Header Styles */
.section-header {
@apply mb-8;
}
.section-title {
@apply text-2xl md:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-2;
}
.section-subtitle {
@apply text-zinc-600 dark:text-zinc-400;
}
}
/* Utility Classes */
/* Utility Classes & Animations */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Animations */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in-down {
animation: fade-in-down 0.6s ease-out forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.6s ease-out forwards;
}
.animate-scale-in {
animation: scale-in 0.4s ease-out forwards;
}
.animation-delay-100 {
animation-delay: 0.1s;
opacity: 0;
}
.animation-delay-200 {
animation-delay: 0.2s;
opacity: 0;
}
.animation-delay-300 {
animation-delay: 0.3s;
opacity: 0;
}
.animation-delay-400 {
animation-delay: 0.4s;
opacity: 0;
}
/* ============================================
HIGHLIGHTS SLIDER - Gemeinsame Styles
============================================ */
/* Gradient Indicator */
.gradient-indicator {
width: 0.5rem;
height: 2.5rem;
background: linear-gradient(to bottom,
hsl(var(--primary)),
hsl(var(--secondary)));
border-radius: 9999px;
display: inline-block;
}
/* Slider Wrapper */
.slider-wrapper {
position: relative;
margin-left: -1.5rem;
margin-right: -1.5rem;
padding: 0.5rem 1rem;
margin-top: -3rem;
}
/* Slider Container */
.highlights-slider {
overflow-x: auto;
overflow-y: visible;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
display: flex;
gap: 1.5rem;
padding: 1.5rem 1.25rem 2rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.highlights-slider::-webkit-scrollbar {
display: none;
}
/* Slider Navigation Buttons */
.slider-nav-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 1px solid;
border-color: rgb(228 228 231 / 1);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
cursor: pointer;
}
.dark .slider-nav-btn {
border-color: rgb(63 63 70 / 1);
}
.slider-nav-btn:hover:not(:disabled) {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.slider-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Slider Dots */
.slider-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0;
}
.slider-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: rgb(212 212 216 / 1);
transition: all 0.3s;
cursor: pointer;
border: none;
padding: 0;
}
.dark .slider-dot {
background: rgb(82 82 91 / 1);
}
.slider-dot.active {
width: 2rem;
background: hsl(var(--primary));
}
/* Highlight Card Link */
.highlight-card-link {
display: block;
flex-shrink: 0;
width: 100%;
scroll-snap-align: center;
text-decoration: none;
}
.highlight-card-link:focus {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
.card-premium {
background: var(--color-white);
box-shadow: var(--shadow-card);
border: 1px solid;
border-color: hsl(var(--primary) / 0.2);
}
.card-premium-hover {
box-shadow: var(--shadow-card-hover);
border-color: hsl(var(--primary) / 0.4);
transform: translateY(-0.125rem);
}
/* Highlight Card */
.highlight-card {
background: linear-gradient(to bottom right,
rgb(244 244 245 / 0.5),
rgb(244 244 245 / 0.25));
border-radius: 0.75rem;
border: 1px solid;
border-color: hsl(var(--primary) / 0.2);
overflow: hidden;
transition: all 0.3s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
height: 100%;
display: flex;
flex-direction: column;
}
.dark .highlight-card {
background: linear-gradient(to bottom right,
rgb(39 39 42 / 1),
rgb(39 39 42 / 0.5));
border-color: rgb(63 63 70 / 1);
}
.highlight-card-link:hover .highlight-card {
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1),
0 8px 10px -6px rgb(0 0 0 / 0.1);
border-color: hsl(var(--primary) / 0.3);
transform: translateY(-0.125rem);
}
/* Highlight Card Image */
.highlight-card-image {
position: relative;
height: 16rem;
overflow: hidden;
background: linear-gradient(to bottom right,
rgb(244 244 245 / 1),
rgb(250 250 250 / 1));
}
.dark .highlight-card-image {
background: linear-gradient(to bottom right,
rgb(39 39 42 / 1),
rgb(24 24 27 / 1));
}
@media (min-width: 768px) {
.highlight-card-image {
height: 100%;
}
}
/* Highlight Badge */
.highlight-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.375rem 0.75rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
backdrop-filter: blur(12px);
border-radius: 0.25rem;
color: white;
}
/* Highlight Card Content */
.highlight-card-content {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1.5rem 2rem;
}
/* Highlight Meta */
.highlight-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.75rem;
line-height: 1rem;
color: rgb(113 113 122 / 1);
}
.dark .highlight-meta {
color: rgb(161 161 170 / 1);
}
/* Highlight Title */
.highlight-title {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
color: rgb(24 24 27 / 1);
margin-bottom: 1rem;
line-height: 1.25;
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 768px) {
.highlight-title {
font-size: 1.875rem;
line-height: 2.25rem;
}
}
.dark .highlight-title {
color: rgb(250 250 250 / 1);
}
.highlight-card-link:hover .highlight-title {
color: hsl(var(--primary));
}
/* Highlight Text */
.highlight-text {
font-size: 1rem;
line-height: 1.625;
color: rgb(82 82 91 / 1);
margin-bottom: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dark .highlight-text {
color: rgb(161 161 170 / 1);
}
/* Highlight Footer */
.highlight-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 1.25rem;
border-top: 1px solid;
border-color: hsl(var(--primary) / 0.1);
}
/* ============================================
SECTION CONTAINERS - Gemeinsame Styles
============================================ */
.section-light-bg {
background: linear-gradient(135deg,
rgb(250 250 250 / 0.5) 0%,
rgb(244 244 245 / 0.5) 100%);
}
.dark .section-light-bg {
background: linear-gradient(135deg,
rgb(24 24 27 / 0.5) 0%,
rgb(39 39 42 / 0.5) 100%);
}
.section-gradient-bg {
background: linear-gradient(to bottom right,
hsl(var(--primary) / 0.05),
hsl(var(--secondary) / 0.05));
border-top: 1px solid;
border-bottom: 1px solid;
border-color: hsl(var(--primary) / 0.1);
}
.dark .section-gradient-bg {
background: linear-gradient(to bottom right,
hsl(var(--primary) / 0.1),
hsl(var(--secondary) / 0.1));
}
/* ============================================
HERO BANNER - Gemeinsame Styles
============================================ */
.hero-gradient {
background: linear-gradient(135deg,
hsl(var(--primary)) 0%,
hsl(var(--secondary)) 100%);
box-shadow: inset 0 -6px 16px rgba(0, 0, 0, 0.16);
position: relative;
}
.dark .hero-gradient {
box-shadow: inset 0 -6px 16px rgba(0, 0, 0, 0.2);
}
.hero-title {
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.hero-subtitle {
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
/* ============================================
INDUSTRY ICONS - Gemeinsame Styles
============================================ */
.industry-icon-badge img {
opacity: 0.6;
}
.dark .industry-icon-badge img {
opacity: 0.6;
filter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%);
}
/* ============================================
PAGE HEADER - Unterseiten
============================================ */
.page-header {
padding: 3rem 0;
background: linear-gradient(135deg,
rgb(250 250 250 / 0.5) 0%,
rgb(244 244 245 / 0.5) 100%);
border-bottom: 1px solid rgb(228 228 231 / 1);
}
.dark .page-header {
background: linear-gradient(135deg,
rgb(24 24 27 / 0.5) 0%,
rgb(39 39 42 / 0.5) 100%);
border-bottom: 1px solid rgb(63 63 70 / 1);
}
.page-header-compact {
padding: 2rem 0;
}
.page-header-title {
font-size: 2.25rem;
line-height: 2.5rem;
font-weight: 700;
color: rgb(24 24 27 / 1);
margin-bottom: 1rem;
}
@media (min-width: 768px) {
.page-header-title {
font-size: 3rem;
line-height: 1.2;
}
}
.dark .page-header-title {
color: rgb(250 250 250 / 1);
}
.page-header-subtitle {
font-size: 1.125rem;
line-height: 1.75rem;
color: rgb(82 82 91 / 1);
}
.dark .page-header-subtitle {
color: rgb(161 161 170 / 1);
}
/* ============================================
SIDEBAR WIDGETS
============================================ */
.sidebar-widget {
background: rgb(255 255 255 / 1);
border: 1px solid rgb(228 228 231 / 1);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.2s;
}
.dark .sidebar-widget {
background: rgb(39 39 42 / 1);
border-color: rgb(63 63 70 / 1);
}
.sidebar-widget:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.sidebar-widget-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgb(228 228 231 / 1);
}
.dark .sidebar-widget-header {
border-bottom-color: rgb(63 63 70 / 1);
}
.sidebar-widget-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(24 24 27 / 1);
}
.dark .sidebar-widget-title {
color: rgb(250 250 250 / 1);
}
.sidebar-widget-content {
color: rgb(82 82 91 / 1);
}
.dark .sidebar-widget-content {
color: rgb(161 161 170 / 1);
}
/* ============================================
NAVIGATION - Aktive States
============================================ */
.main-nav-link.active {
color: hsl(var(--primary));
}
.main-nav-link.active::after {
width: 100%;
}
/* ============================================
PAGINATION - Gradient für aktive Seite
============================================ */
.pagination-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.7);
color: var(--zinc-900);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
cursor: pointer;
}
.dark .pagination-btn {
background: rgba(255, 255, 255, 0.2);
color: var(--zinc-100);
}
.pagination-btn:hover:not(.pagination-active):not(:disabled) {
background: var(--zinc-100);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.dark .pagination-btn:hover:not(.pagination-active):not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.pagination-active, .dark .pagination-active {
background: linear-gradient(90deg,
hsl(var(--primary)) 0%,
hsl(var(--secondary)) 100%);
box-shadow: var(--shadow-md);
color: white;
}
.pagination-active:hover {
background: linear-gradient(90deg,
hsl(var(--primary) / 0.9) 0%,
hsl(var(--secondary) / 0.9) 100%);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.pagination-nav {
background: transparent;
box-shadow: none;
color: #6b7280;
}
.dark .pagination-nav {
color: #9ca3af;
}
.pagination-nav:hover:not(:disabled) {
color: #111827;
background: transparent;
box-shadow: none;
transform: none;
}
.dark .pagination-nav:hover:not(:disabled) {
color: #f3f4f6;
}
}

View file

@ -1,291 +1,244 @@
/**
* Theme für Businessportal24 (businessportal24.test)
* Primary: #cf3628 (Rot)
* Secondary: #f0834a (Orange)
* Font: Montserrat
* Theme für businessportal24 (businessportal24.test)
* Editorialer Wirtschaftsteil Mockup "Pass B"
*
* Tailwind v4: alle Farben + Schriften liegen als @theme-Tokens vor,
* sodass Klassen wie `bg-bg`, `text-ink-3`, `border-bg-rule` arbeiten
* (1:1 wie im Tailwind-v3-Mockup unter dev/frontend/tailwind_v3/).
*/
@import "./shared-styles.css";
/* Theme-spezifische CSS-Variablen */
@theme {
/* Surfaces */
--color-bg: #f6f4ef;
--color-bg-elev: #fbfaf6;
--color-bg-rule: #e5e0d5;
--color-bg-rule-strong: #1c1a17;
--color-bg-dark: #14202e;
--color-bg-card-warm: #f1ece2;
--color-bg-card-warm-border: #d9cdb6;
--color-topbar: #1a1f26;
--color-topbar2: #232a33;
/* Ink */
--color-ink: #1c1a17;
--color-ink-2: #3d3935;
--color-ink-3: #6e6862;
--color-ink-4: #9a958d;
--color-ink-on-dark: #f6f4ef;
--color-ink-on-dark-2: #b8b3aa;
/* Brand */
--color-brand: #c84a1e;
--color-brand-deep: #a23814;
--color-brand-soft: #f4e5dd;
--color-live: #e03a1a;
--color-gain: #2e8540;
--color-loss: #c8341e;
--color-ok: #2e8540;
/* Editorial-Akzente (auf dunklem Grund / in Card-Warm) */
--color-accent-warm: #ff8b6f;
--color-ink-on-dark-muted: #8a847b;
--color-ink-on-dark-rule: #2a2723;
--color-bg-card-warm-hover: #ece5d5;
--color-bg-card-warm-rule: #c8bda8;
--color-card-warm-cat: #6e6862;
--color-card-warm-title: #3a332b;
--color-feature-line: #a8c8a8;
--color-feature-dot: #c4dcc4;
/* Fonts */
--font-sans: "Inter Tight", "Söhne", Inter, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-serif: "Source Serif 4", "Source Serif Pro", Charter,
"Iowan Old Style", Georgia, serif;
--font-mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
/* Layout */
--container-layout: 1280px;
}
/* HSL-Variablen für vorhandene Shared-Components (Pagination, Cards, etc.) */
:root {
/* Font-Familie */
--font-primary: "Montserrat", ui-sans-serif, system-ui, sans-serif;
--font-secondary: "Montserrat", ui-sans-serif, system-ui, sans-serif;
/* HSL-Variablen basierend auf Primary Color #cf3628 */
--background: 0 0% 100%;
--foreground: 4 61% 20%;
--card: 0 0% 100%;
--card-foreground: 4 61% 20%;
--font-primary: var(--font-sans);
--font-secondary: var(--font-sans);
--background: 39 27% 95%;
--foreground: 24 8% 10%;
--card: 39 27% 95%;
--card-foreground: 24 8% 10%;
--popover: 0 0% 100%;
--popover-foreground: 4 61% 20%;
--popover-foreground: 24 8% 10%;
/* Primary: #cf3628 -> hsl(4, 61%, 48%) */
--primary: 4 61% 48%;
--primary: 13 74% 45%;
--primary-foreground: 0 0% 100%;
--primary-50: 4 60% 96%;
--primary-100: 4 61% 92%;
--primary-200: 4 61% 85%;
--primary-300: 4 61% 75%;
--primary-400: 4 61% 65%;
--primary-500: 4 61% 55%;
--primary-600: 4 61% 48%;
--primary-700: 4 61% 40%;
--primary-800: 4 61% 32%;
--primary-900: 4 61% 25%;
--primary-950: 4 61% 20%;
--primary-50: 13 70% 96%;
--primary-100: 13 70% 92%;
--primary-200: 13 70% 85%;
--primary-300: 13 70% 75%;
--primary-400: 13 70% 65%;
--primary-500: 13 74% 55%;
--primary-600: 13 74% 45%;
--primary-700: 13 74% 38%;
--primary-800: 13 74% 30%;
--primary-900: 13 74% 25%;
--primary-950: 13 74% 18%;
/* Secondary: #f0834a -> hsl(21, 84%, 62%) */
--secondary: 21 84% 62%;
--secondary-foreground: 4 61% 20%;
--secondary-foreground: 24 8% 10%;
--muted: 4 10% 95%;
--muted-foreground: 4 10% 45%;
--accent: 4 61% 48%;
--muted: 36 25% 90%;
--muted-foreground: 30 6% 40%;
--accent: 13 74% 45%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 39 24% 86%;
--input: 39 24% 86%;
--ring: 13 74% 45%;
--radius: 2px;
--border: 4 10% 90%;
--input: 4 10% 90%;
--ring: 4 61% 48%;
--radius: 0.5rem;
/* Gradient für Hero */
--gradient-hero: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%);
/* Shadow-Token */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Warning Colors für Anzeige-Badge */
--warning: 45 93% 47%;
--warning-foreground: 26 83% 14%;
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.04);
--shadow-card-hover: 0 4px 12px -2px rgb(0 0 0 / 0.08);
}
/* Dark Mode Variablen (falls gewünscht) */
@custom-variant dark (&:where(.dark, .dark *));
@layer theme {
.dark {
--background: 4 20% 10%;
--foreground: 0 0% 95%;
--card: 4 20% 12%;
--card-foreground: 0 0% 95%;
--popover: 4 20% 12%;
--popover-foreground: 0 0% 95%;
--primary: 21 84% 62%;
--secondary: 4 61% 48%;
--muted: 4 20% 20%;
--muted-foreground: 4 10% 70%;
--accent: 21 84% 62%;
--accent-foreground: 0 0% 10%;
--border: 4 20% 20%;
--input: 4 20% 20%;
--ring: 21 84% 62%;
}
}
/* Theme-spezifische Overrides */
@layer base {
html,
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
color: hsl(var(--primary));
body {
background-color: #e8e4da;
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
a:hover {
color: hsl(var(--secondary));
h1,
h2,
h3,
h4 {
font-family: var(--font-serif);
color: inherit;
letter-spacing: -0.01em;
}
.font-mono {
font-family: var(--font-mono);
font-feature-settings: "tnum";
}
}
@layer components {
/* Button Styles */
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white shadow-md transition-all duration-300 hover:shadow-lg;
background-image: linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)));
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-brand);
}
.eyebrow.muted {
color: var(--color-ink-3);
}
.eyebrow.on-dark {
color: var(--color-accent-warm);
}
.btn-primary:hover {
.bp-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-brand);
}
.bp-tag {
display: inline-flex;
align-items: center;
padding: 3px 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-brand);
color: #fff;
border-radius: 2px;
}
.rule-strong {
height: 1px;
background: var(--color-bg-rule-strong);
border: 0;
margin: 0;
}
.rule {
height: 1px;
background: var(--color-bg-rule);
border: 0;
margin: 0;
}
.bg-topbar-grad {
background-image: linear-gradient(
to right,
hsl(var(--primary) / 0.9),
hsl(var(--secondary) / 0.9)
135deg,
var(--color-topbar) 0%,
var(--color-topbar2) 100%
);
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-all duration-200 hover:bg-gray-50;
.bg-feature-grad {
background-image: linear-gradient(135deg, #2a3a2a, #1a2a1a);
}
/* Card Styles */
.card {
@apply rounded-xl border transition-all duration-300;
background-color: hsl(var(--card));
border-color: hsl(var(--border));
box-shadow: var(--shadow-card);
}
.card:hover {
box-shadow: var(--shadow-card-hover);
}
.card-hover {
@apply hover:scale-[1.02];
}
.card-hover:hover {
border-color: hsl(var(--primary) / 0.2);
}
/* Shadow utilities */
.shadow-card {
box-shadow: var(--shadow-card);
}
.shadow-card-hover {
box-shadow: var(--shadow-card-hover);
}
/* Input Styles */
.input-primary {
@apply w-full rounded-lg border px-4 py-2 text-sm transition-all duration-200;
border-color: hsl(var(--input));
}
.input-primary:focus {
@apply outline-none;
--tw-ring-color: hsl(var(--primary));
box-shadow: 0 0 0 2px var(--tw-ring-color);
border-color: transparent;
}
/* Badge Styles */
.badge {
@apply inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200;
}
.badge-primary {
@apply border;
background-color: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.2);
}
.badge-secondary {
@apply border text-gray-700;
background-color: hsl(var(--secondary) / 0.1);
border-color: hsl(var(--secondary) / 0.2);
.bg-hero-grad {
background-image: linear-gradient(135deg, #2a3a4a, #1a1f26);
}
}
/* Animations */
@layer utilities {
@keyframes fade-in {
from {
opacity: 0;
}
to {
@keyframes bp-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
.pulse-dot {
animation: bp-pulse 1.6s ease-in-out infinite;
}
@keyframes fade-in-down {
@keyframes bp-ticker-marquee {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
transform: translateX(-50%);
}
}
.ticker-marquee-track {
display: flex;
width: max-content;
gap: 1.5rem;
animation: bp-ticker-marquee var(--bp-ticker-duration, 42s) linear infinite;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.ticker-marquee-track {
animation: none;
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in-down {
animation: fade-in-down 0.6s ease-out forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.6s ease-out forwards;
}
.animate-scale-in {
animation: scale-in 0.4s ease-out forwards;
}
.animation-delay-100 {
animation-delay: 0.1s;
opacity: 0;
}
.animation-delay-200 {
animation-delay: 0.2s;
opacity: 0;
}
.animation-delay-300 {
animation-delay: 0.3s;
opacity: 0;
}
.animation-delay-400 {
animation-delay: 0.4s;
opacity: 0;
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
/* Alpine.js: vor Init versteckte Elemente */
[x-cloak] {
display: none !important;
}

View file

@ -1,291 +1,248 @@
/**
* Theme für Presseecho (presseecho.test)
* Primary: #345636 (Grün)
* Secondary: #6b8f71 (Hellgrün)
* Font: Montserrat
* Theme für presseecho (presseecho.test)
* Editorialer Wirtschaftsteil analoge Architektur zu businessportal24,
* aber mit grüner Markenpalette (Primary: #345636).
*
* Tailwind v4: alle Farben + Schriften liegen als @theme-Tokens vor,
* sodass Klassen wie `bg-bg`, `text-ink-3`, `border-bg-rule` arbeiten
* (gleiche Token-Namen wie bei businessportal24 Komponenten-Reuse).
*
* Die alten HSL-Variablen aus shadcn-Style bleiben am Ende erhalten,
* damit Legacy-Komponenten (`hero-banner`, `article-card`, etc.) nicht brechen.
*/
@import "./shared-styles.css";
/* Theme-spezifische CSS-Variablen */
@theme {
/* Surfaces */
--color-bg: #f2f4ed;
--color-bg-elev: #fafbf7;
--color-bg-rule: #dde2d3;
--color-bg-rule-strong: #1b2417;
--color-bg-dark: #15201a;
--color-bg-card-warm: #ecefe3;
--color-bg-card-warm-border: #c7cfb6;
--color-topbar: #1b2a1f;
--color-topbar2: #25342a;
/* Ink */
--color-ink: #1b2417;
--color-ink-2: #324132;
--color-ink-3: #6a7766;
--color-ink-4: #98a294;
--color-ink-on-dark: #f0f4eb;
--color-ink-on-dark-2: #b1b9ab;
/* Brand (Presseecho-grün) */
--color-brand: #345636;
--color-brand-deep: #243c25;
--color-brand-soft: #dbe7d3;
--color-live: #e03a1a;
--color-gain: #2e8540;
--color-loss: #c8341e;
--color-ok: #2e8540;
/* Editorial-Akzente (auf dunklem Grund / in Card-Warm) */
--color-accent-warm: #e8a95f;
--color-ink-on-dark-muted: #859485;
--color-ink-on-dark-rule: #28332b;
--color-bg-card-warm-hover: #dde3cc;
--color-bg-card-warm-rule: #b7c0a2;
--color-card-warm-cat: #5f6a52;
--color-card-warm-title: #2e3826;
--color-feature-line: #a8c8a8;
--color-feature-dot: #c4dcc4;
/* Fonts */
--font-sans: "Inter Tight", "Söhne", Inter, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-serif: "Source Serif 4", "Source Serif Pro", Charter,
"Iowan Old Style", Georgia, serif;
--font-mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
/* Layout */
--container-layout: 1280px;
}
/* HSL-Variablen für Legacy-Komponenten (shared-styles, alte presseecho-Karten) */
:root {
/* Font-Familie */
--font-primary: "Montserrat", ui-sans-serif, system-ui, sans-serif;
--font-secondary: "Montserrat", ui-sans-serif, system-ui, sans-serif;
/* HSL-Variablen basierend auf Primary Color #345636 */
--background: 0 0% 100%;
--foreground: 126 24% 20%;
--card: 0 0% 100%;
--card-foreground: 126 24% 20%;
--font-primary: var(--font-sans);
--font-secondary: var(--font-sans);
--background: 87 22% 94%;
--foreground: 90 28% 12%;
--card: 87 22% 94%;
--card-foreground: 90 28% 12%;
--popover: 0 0% 100%;
--popover-foreground: 126 24% 20%;
--popover-foreground: 90 28% 12%;
/* Primary: #345636 -> hsl(126, 24%, 27%) */
--primary: 126 24% 27%;
/* Primary: #345636 -> hsl(124, 24%, 27%) */
--primary: 124 24% 27%;
--primary-foreground: 0 0% 100%;
--primary-50: 126 24% 96%;
--primary-100: 126 24% 92%;
--primary-200: 126 24% 85%;
--primary-300: 126 24% 75%;
--primary-400: 126 24% 65%;
--primary-500: 126 24% 55%;
--primary-600: 126 24% 48%;
--primary-700: 126 24% 40%;
--primary-800: 126 24% 32%;
--primary-900: 126 24% 25%;
--primary-950: 126 24% 20%;
--primary-50: 124 24% 96%;
--primary-100: 124 24% 92%;
--primary-200: 124 24% 85%;
--primary-300: 124 24% 75%;
--primary-400: 124 24% 65%;
--primary-500: 124 24% 55%;
--primary-600: 124 24% 48%;
--primary-700: 124 24% 40%;
--primary-800: 124 24% 32%;
--primary-900: 124 24% 25%;
--primary-950: 124 24% 18%;
/* Secondary: #6b8f71 -> hsl(130, 15%, 49%) */
--secondary: 130 15% 49%;
--secondary-foreground: 126 24% 20%;
--secondary-foreground: 90 28% 12%;
--muted: 126 10% 95%;
--muted-foreground: 126 10% 45%;
--accent: 126 24% 27%;
--muted: 90 10% 90%;
--muted-foreground: 90 8% 38%;
--accent: 124 24% 27%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 90 16% 84%;
--input: 90 16% 84%;
--ring: 124 24% 27%;
--radius: 2px;
--border: 126 10% 90%;
--input: 126 10% 90%;
--ring: 126 24% 27%;
--radius: 0.5rem;
/* Gradient für Hero */
--gradient-hero: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%);
/* Shadow-Token */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Warning Colors für Anzeige-Badge */
--warning: 45 93% 47%;
--warning-foreground: 26 83% 14%;
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.04);
--shadow-card-hover: 0 4px 12px -2px rgb(0 0 0 / 0.08);
}
/* Dark Mode Variablen (falls gewünscht) */
@custom-variant dark (&:where(.dark, .dark *));
@layer theme {
.dark {
--background: 126 20% 10%;
--foreground: 0 0% 95%;
--card: 126 20% 12%;
--card-foreground: 0 0% 95%;
--popover: 126 20% 12%;
--popover-foreground: 0 0% 95%;
--primary: 130 15% 49%;
--secondary: 126 24% 27%;
--muted: 126 20% 20%;
--muted-foreground: 126 10% 70%;
--accent: 130 15% 49%;
--accent-foreground: 0 0% 10%;
--border: 126 20% 20%;
--input: 126 20% 20%;
--ring: 130 15% 49%;
}
}
/* Theme-spezifische Overrides */
@layer base {
html,
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
color: hsl(var(--primary));
body {
background-color: #e6e8de;
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
a:hover {
color: hsl(var(--secondary));
h1,
h2,
h3,
h4 {
font-family: var(--font-serif);
color: inherit;
letter-spacing: -0.01em;
}
.font-mono {
font-family: var(--font-mono);
font-feature-settings: "tnum";
}
}
@layer components {
/* Button Styles */
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white shadow-md transition-all duration-300 hover:shadow-lg;
background-image: linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)));
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-brand);
}
.eyebrow.muted {
color: var(--color-ink-3);
}
.eyebrow.on-dark {
color: var(--color-accent-warm);
}
.btn-primary:hover {
.bp-cat {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-brand);
}
.bp-tag {
display: inline-flex;
align-items: center;
padding: 3px 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--color-brand);
color: #fff;
border-radius: 2px;
}
.rule-strong {
height: 1px;
background: var(--color-bg-rule-strong);
border: 0;
margin: 0;
}
.rule {
height: 1px;
background: var(--color-bg-rule);
border: 0;
margin: 0;
}
.bg-topbar-grad {
background-image: linear-gradient(
to right,
hsl(var(--primary) / 0.9),
hsl(var(--secondary) / 0.9)
135deg,
var(--color-topbar) 0%,
var(--color-topbar2) 100%
);
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-all duration-200 hover:bg-gray-50;
.bg-feature-grad {
background-image: linear-gradient(135deg, #2c4733, #15281c);
}
/* Card Styles */
.card {
@apply rounded-xl border transition-all duration-300;
background-color: hsl(var(--card));
border-color: hsl(var(--border));
box-shadow: var(--shadow-card);
}
.card:hover {
box-shadow: var(--shadow-card-hover);
}
.card-hover {
@apply hover:scale-[1.02];
}
.card-hover:hover {
border-color: hsl(var(--primary) / 0.2);
}
/* Shadow utilities */
.shadow-card {
box-shadow: var(--shadow-card);
}
.shadow-card-hover {
box-shadow: var(--shadow-card-hover);
}
/* Input Styles */
.input-primary {
@apply w-full rounded-lg border px-4 py-2 text-sm transition-all duration-200;
border-color: hsl(var(--input));
}
.input-primary:focus {
@apply outline-none;
--tw-ring-color: hsl(var(--primary));
box-shadow: 0 0 0 2px var(--tw-ring-color);
border-color: transparent;
}
/* Badge Styles */
.badge {
@apply inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200;
}
.badge-primary {
@apply border;
background-color: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.2);
}
.badge-secondary {
@apply border text-gray-700;
background-color: hsl(var(--secondary) / 0.1);
border-color: hsl(var(--secondary) / 0.2);
.bg-hero-grad {
background-image: linear-gradient(135deg, #2c4733, #1a2a1f);
}
}
/* Animations */
@layer utilities {
@keyframes fade-in {
from {
opacity: 0;
}
to {
@keyframes bp-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
.pulse-dot {
animation: bp-pulse 1.6s ease-in-out infinite;
}
@keyframes fade-in-down {
@keyframes bp-ticker-marquee {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
transform: translateX(-50%);
}
}
.ticker-marquee-track {
display: flex;
width: max-content;
gap: 1.5rem;
animation: bp-ticker-marquee var(--bp-ticker-duration, 42s) linear infinite;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.ticker-marquee-track {
animation: none;
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in-down {
animation: fade-in-down 0.6s ease-out forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.6s ease-out forwards;
}
.animate-scale-in {
animation: scale-in 0.4s ease-out forwards;
}
.animation-delay-100 {
animation-delay: 0.1s;
opacity: 0;
}
.animation-delay-200 {
animation-delay: 0.2s;
opacity: 0;
}
.animation-delay-300 {
animation-delay: 0.3s;
opacity: 0;
}
.animation-delay-400 {
animation-delay: 0.4s;
opacity: 0;
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
[x-cloak] {
display: none !important;
}

View file

@ -0,0 +1,5 @@
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();

View file

@ -0,0 +1,343 @@
# Backend-Status: presseportale.test
**Projekt:** BusinessPortal24 → Laravel 12 Migration
**Domain:** presseportale.test
**Stand:** 23. Januar 2026 nach Server-Neustart
**Status:** 🟡 Admin-UI-Gerüst vorhanden, Routing auf bestehende Volt-Komponenten konsolidiert
---
## ✅ Weitgehend implementiert (Gerüst)
### 📐 Struktur & Navigation
#### 1. Sidebar-Navigation (`components/layouts/app/sidebar.blade.php`)
- ✅ Dashboard
- ✅ Content-Bereich (Pressemitteilungen, Kategorien)
- ✅ CRM-Bereich (Firmen, Kontakte)
- ✅ Billing-Bereich (Rechnungen, Zahlungen, Gutscheine)
- ✅ Administration-Bereich (Benutzer, Rollen & Rechte)
- ✅ System-Bereich (Scheduler, Newsletter, Einstellungen)
#### 2. Routing (`routes/admin.php`)
- ✅ **24 Admin-Routes** definiert (nur bestehende Volt-Ziele)
- ✅ Alle aktiven Bereiche technisch konsistent
- ✅ RESTful Namenskonvention
---
## 📄 Erstellte Views (17 Admin-Views)
```
resources/views/admin/
├── dashboard.blade.php ✅ (bereits vorhanden)
├── README.md ✅
├── FLUX_COMPONENTS.md ✅
├── categories/
│ └── index.blade.php ✅
├── companies/
│ ├── index.blade.php ✅
│ ├── create.blade.php ✅
│ ├── show.blade.php ✅
│ └── edit.blade.php ✅
├── contacts/
│ └── index.blade.php ✅
├── coupons/
│ └── index.blade.php ✅
├── invoices/
│ └── index.blade.php ✅
├── payments/
│ └── index.blade.php ✅
├── press-releases/
│ ├── index.blade.php ✅
│ ├── create.blade.php ✅
│ ├── show.blade.php ✅
│ └── edit.blade.php ✅
└── roles/
├── index.blade.php ✅
├── create.blade.php ✅
└── edit.blade.php ✅
```
---
## 🎨 Livewire-Komponenten (16 Komponenten)
```
resources/views/livewire/admin/
├── users.blade.php ✅ (bereits vorhanden)
├── users/
│ └── table.blade.php ✅ (bereits vorhanden)
├── categories/
│ └── index.blade.php ✅
├── companies/
│ ├── index.blade.php ✅
│ ├── create.blade.php ✅
│ ├── show.blade.php ✅
│ └── edit.blade.php ✅
├── contacts/
│ └── index.blade.php ✅
├── coupons/
│ └── index.blade.php ✅
├── invoices/
│ └── index.blade.php ✅
├── payments/
│ └── index.blade.php ✅
├── press-releases/
│ ├── index.blade.php ✅
│ ├── create.blade.php ✅
│ ├── show.blade.php ✅
│ └── edit.blade.php ✅
└── roles/
├── index.blade.php ✅
├── create.blade.php ✅
└── edit.blade.php ✅
```
---
## 🎯 Features der erstellten Views
### 📰 Pressemitteilungen (komplett)
- ✅ **Index:** Volltext-Suche, Status-/Sprachfilter, Statistiken, Sortierung
- ✅ **Create:** Vollständiges Formular (Titel, Content, Kategorien, SEO, Medien)
- ✅ **Show:** Detailansicht mit Metadaten, verwandten Artikeln
- ✅ **Edit:** Bearbeitungsmaske mit allen Feldern
**Features:**
- Multi-Kategorie-Auswahl (Checkboxen)
- Image-Upload mit Vorschau
- SEO-Felder (Meta Title, Description, Keywords)
- Status-Verwaltung (Draft, Published, Archived)
- Veröffentlichungsdatum
---
### 🏢 Firmen (komplett)
- ✅ **Index:** Suche, Aktiv/Inaktiv-Filter, Statistiken
- ✅ **Create:** Firmendaten, Adresse, rechtliche Infos, Logo-Upload
- ✅ **Show:** Übersicht mit Kontakten, PMs, Rechnungen
- ✅ **Edit:** Vollständige Bearbeitungsmaske
**Features:**
- Logo-Upload mit Vorschau
- Adress-Verwaltung (Straße, PLZ, Stadt, Land)
- Rechtliche Daten (Steuernummer, Handelsregister)
- Status-Management (Verifiziert, Aktiv/Inaktiv)
- Verknüpfung zu Kontakten und PMs
---
### 💳 Rechnungen
- ✅ **Index:** Status-Filter, Finanz-Statistiken, Mahnwesen
**Features:**
- Überfälligkeits-Anzeige (farblich hervorgehoben)
- PDF-Download-Button (vorbereitet)
- Mahnung-Button für überfällige Rechnungen
- Gesamtumsatz, Bezahlt, Offen-Statistiken
---
### 💰 Zahlungen
- ✅ **Index:** Status-/Methoden-Filter, Transaktions-IDs, Statistiken
**Features:**
- Stripe/Rechnung-Unterscheidung
- Status-Badges (Erfolgreich, Ausstehend, Fehlgeschlagen)
- Transaktions-ID-Anzeige
- Retry-Button für fehlgeschlagene Zahlungen
---
### 👥 Kontakte
- ✅ **Index:** Suche, Firmen-Filter, Kontaktdetails
**Features:**
- Zuordnung zu Firmen
- Mailto-Links
- Position/Verantwortlichkeit
- Statistiken (Durchschnitt pro Firma)
---
### 🏷️ Kategorien
- ✅ **Index:** Grid-Ansicht, Mehrsprachigkeit (DE/EN)
**Features:**
- Karten-Layout statt Tabelle
- Zuordnung zu Pressemitteilungen
- Slug-Anzeige
- Dropdown-Menü mit Aktionen
---
### 🎟️ Gutscheine
- ✅ **Index:** Status-Filter, Verwendungsstatistiken
**Features:**
- Verwendungsfortschritt (Balken)
- Prozent- vs. Festbetrag-Rabatte
- Gültigkeitsprüfung
- Copy-Button für Code
---
### 🛡️ Rollen & Rechte (komplett)
- ✅ **Index:** Tab-basiert (Rollen/Berechtigungen)
- ✅ **Create:** Rollen-Erstellung mit Berechtigungsauswahl
- ✅ **Edit:** Bearbeitung mit System-Rollen-Warnung
**Features:**
- Gruppierte Berechtigungen
- Farb-Badges für Rollen
- Benutzer-/Berechtigungszähler
- Multi-Select für Permissions
---
## 🎨 Design-System
### Flux UI v2 (korrekte Syntax)
- ✅ `<flux:table.columns>` / `<flux:table.column>`
- ✅ `<flux:table.rows>` / `<flux:table.row>` / `<flux:table.cell>`
- ✅ Alle Komponenten dokumentiert in `FLUX_COMPONENTS.md`
### Konsistentes Design
- ✅ Statistik-Cards überall
- ✅ Einheitliche Filter-Bars
- ✅ Empty States mit Icons
- ✅ Status-Badges mit Farben
- ✅ Responsive Grid-Layouts
- ✅ Dark Mode Support
---
## 📊 Statistiken
### Dateien erstellt: **33**
- 17 Admin-Views
- 16 Livewire-Komponenten
### Bereiche abgedeckt: **7**
1. ✅ Content Management (Press Releases, Categories)
2. ✅ CRM (Companies, Contacts)
3. ✅ Billing (Invoices, Payments, Coupons)
4. ✅ Administration (Users, Roles)
5. ✅ Dashboard
6. ⏳ System (Scheduler, Newsletter, Settings) - TODO
7. ⏳ User Settings - bereits vorhanden
### Routes definiert: **24**
- Alle aktiven Bereiche konsistent geroutet
---
## ⏳ Noch zu erstellen (optional)
### System-Bereich
- ⏳ Scheduler Jobs Index
- ⏳ Newsletter Management (Index, Subscribers, Campaigns)
- ⏳ System-Einstellungen
### Detail-Views für einige Bereiche
- ⏳ Invoice Show (Detail mit PDF)
- ⏳ Payment Show (Transaktionsdetails)
- ⏳ Contact Create/Edit (Formular)
- ⏳ Category Create/Edit (Formular)
- ⏳ Coupon Create/Edit (Formular)
### Nicht aktive Bereiche (noch ohne Route)
- ⏳ Kategorien: Create/Edit
- ⏳ Kontakte: Create/Edit
- ⏳ Rechnungen: Show
- ⏳ Zahlungen: Show
- ⏳ Gutscheine: Create/Edit
- ⏳ Benutzer: Create/Edit
- ⏳ System: Scheduler, Newsletter, Einstellungen
---
## 🔄 Nächste Schritte
### Option 1: Migrations-Projekt starten 🔴 (Empfohlen)
Beginne mit der eigentlichen Migration gemäß:
- `/var/www/html/_businessportal24.com/dev/migration/CHECKLIST.md`
- Phase 1: Laravel-Setup, Packages, DB-Verbindung
- Phase 2: Domain Models erstellen
- Phase 3: Services implementieren
### Option 2: Fehlende System-Views erstellen 🟡
- Scheduler Jobs Management
- Newsletter Management
- System-Einstellungen
### Option 3: Backend testen 🟢
- Dev-Server starten (`npm run dev`)
- Backend öffnen (`http://presseportale.test/admin/press-releases`)
- UI und Navigation prüfen
### Option 4: Dummy-Daten verfeinern 🟢
- Mehr realistische Test-Daten
- Pagination implementieren
- Filter-Logik verbessern
---
## 🧪 Testen des Backends
```bash
# Dev-Server starten (Terminal 1)
npm run dev:portal
# Laravel-Server starten (Terminal 2)
php artisan serve
# Backend öffnen
http://presseportale.test/admin/press-releases
http://presseportale.test/admin/companies
http://presseportale.test/admin/invoices
http://presseportale.test/admin/contacts
http://presseportale.test/admin/payments
http://presseportale.test/admin/categories
http://presseportale.test/admin/coupons
http://presseportale.test/admin/roles
```
---
## 📚 Dokumentation
### Erstellt
- ✅ `resources/views/admin/README.md` - Ordnerübersicht
- ✅ `resources/views/admin/FLUX_COMPONENTS.md` - Flux v2 Referenz
- ✅ `routes/ADMIN_ROUTES.md` - Route-Dokumentation
- ✅ `resources/views/admin/BACKEND_STATUS.md` - Dieser Status
### Migrations-Dokumentation
- `/var/www/html/_businessportal24.com/dev/migration/`
- `CHECKLIST.md` - 127 Migrations-Aufgaben
- `PROGRESS.md` - Verlaufsprotokoll
- `IMPLEMENTATION.md` - Schritt-für-Schritt
- `API-MIGRATION.md` - API-Details
- `DATA-MIGRATION.md` - Datenbank-Migration
- `TESTING.md` - Test-Strategie
---
## 🎉 Meilenstein erreicht!
**Backend-Struktur für presseportale.test ist als Gerüst weitgehend vorbereitet.**
- ✅ 7 Hauptbereiche mit Navigation
- ✅ 24 Routes definiert (konsistent gemappt)
- ✅ 33 Views/Komponenten erstellt
- ✅ Flux UI v2 korrekte Syntax
- ✅ Responsive & Dark Mode
- ✅ Vorbereitet für echte Daten
**Bereit für die nächste Phase: Migration der Symfony-Daten und Model-Implementation!**
---
**Letztes Update:** 23. Januar 2026 (nach Server-Neustart)

View file

@ -0,0 +1,516 @@
# Flux UI v2 - Komponenten-Referenz
**Projekt:** presseportale.test Backend
**Flux Version:** 2.x
**Stand:** 23. Januar 2026
---
## ⚠️ Korrekte Flux v2 Syntax
### ✅ Offizielle Syntax (v2.x)
**Quelle:** [Flux UI Table Documentation](https://fluxui.dev/components/table)
```blade
<flux:table>
<flux:table.columns>
<flux:table.column>Name</flux:table.column>
</flux:table.columns>
<flux:table.rows>
<flux:table.row>
<flux:table.cell>Value</flux:table.cell>
</flux:table.row>
</flux:table.rows>
</flux:table>
```
**Wichtig:** Flux nutzt **Punktnotation** (`.`) für verschachtelte Komponenten!
---
## 📋 Tabellen (Tables)
### Einfache Tabelle
```blade
<flux:table>
<flux:table.columns>
<flux:table.column>Name</flux:table.column>
<flux:table.column>Email</flux:table.column>
<flux:table.column>Status</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach($users as $user)
<flux:table.row :key="$user->id">
<flux:table.cell>{{ $user->name }}</flux:table.cell>
<flux:table.cell>{{ $user->email }}</flux:table.cell>
<flux:table.cell>
<flux:badge color="green">Active</flux:badge>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
```
### Tabelle mit Empty State
```blade
<flux:table>
<flux:table.columns>
<flux:table.column>Spalte 1</flux:table.column>
<flux:table.column>Spalte 2</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($items as $item)
<flux:table.row :key="$item->id">
<flux:table.cell>{{ $item->value }}</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="2">
<div class="flex flex-col items-center justify-center py-12">
<flux:icon.inbox class="size-12 text-zinc-400" />
<flux:text class="mt-4 text-zinc-500">Keine Daten gefunden</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
```
---
## 🎨 Cards
### Einfache Card
```blade
<flux:card>
<flux:heading>Überschrift</flux:heading>
<flux:text>Inhalt der Card</flux:text>
</flux:card>
```
### Card mit statistiken
```blade
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500">Label</flux:text>
<flux:text size="xl" weight="bold">1,234</flux:text>
</div>
<flux:icon.chart-bar class="size-8 text-blue-500" />
</div>
</flux:card>
```
---
## 🔘 Buttons
### Standard Button
```blade
<flux:button>Klick mich</flux:button>
```
### Button mit Icon
```blade
<flux:button icon="plus">Neu erstellen</flux:button>
<flux:button icon-trailing="arrow-right">Weiter</flux:button>
```
### Button-Varianten
```blade
<flux:button variant="primary">Primary</flux:button>
<flux:button variant="ghost">Ghost</flux:button>
<flux:button variant="danger">Danger</flux:button>
```
### Button-Größen
```blade
<flux:button size="sm">Klein</flux:button>
<flux:button size="md">Mittel</flux:button>
<flux:button size="lg">Groß</flux:button>
```
### Link-Button (Livewire Navigate)
```blade
<flux:button href="{{ route('admin.users') }}" wire:navigate>
Benutzer
</flux:button>
```
---
## 🏷️ Badges
### Standard Badge
```blade
<flux:badge>Standard</flux:badge>
```
### Badge mit Farben
```blade
<flux:badge color="green">Erfolg</flux:badge>
<flux:badge color="red">Fehler</flux:badge>
<flux:badge color="yellow">Warnung</flux:badge>
<flux:badge color="blue">Info</flux:badge>
<flux:badge color="gray">Neutral</flux:badge>
<flux:badge color="zinc">Default</flux:badge>
```
### Badge mit Icon
```blade
<flux:badge color="green" icon="check">Aktiv</flux:badge>
<flux:badge color="red" icon="x-mark">Inaktiv</flux:badge>
```
### Badge-Größen
```blade
<flux:badge size="sm">Klein</flux:badge>
<flux:badge size="md">Mittel</flux:badge>
<flux:badge size="lg">Groß</flux:badge>
```
---
## 📝 Formulare
### Input
```blade
<flux:input
wire:model="name"
label="Name"
placeholder="Geben Sie Ihren Namen ein"
icon="user"
/>
```
### Input mit Fehler
```blade
<flux:input
wire:model="email"
label="E-Mail"
:error="$errors->first('email')"
/>
```
### Select
```blade
<flux:select wire:model="status" label="Status">
<option value="">Bitte wählen...</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</flux:select>
```
### Textarea
```blade
<flux:textarea
wire:model="description"
label="Beschreibung"
rows="5"
/>
```
### Checkbox
```blade
<flux:checkbox wire:model="terms">
Ich akzeptiere die AGB
</flux:checkbox>
```
### Radio
```blade
<flux:radio wire:model="type" value="option1">Option 1</flux:radio>
<flux:radio wire:model="type" value="option2">Option 2</flux:radio>
```
---
## 🔍 Suche & Filter
### Suchfeld
```blade
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Suchen..."
icon="magnifying-glass"
/>
```
### Filter-Kombination
```blade
<div class="flex gap-4">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Suchen..."
icon="magnifying-glass"
class="flex-1"
/>
<flux:select wire:model.live="status" class="w-40">
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</flux:select>
</div>
```
---
## 🎭 Icons (Hero Icons)
### Icon-Verwendung
```blade
<flux:icon.user class="size-6" />
<flux:icon.check-circle class="size-8 text-green-500" />
<flux:icon.x-mark class="size-4" />
```
### Häufig verwendete Icons
```blade
{{-- Allgemein --}}
<flux:icon.home />
<flux:icon.cog />
<flux:icon.bell />
{{-- Benutzer --}}
<flux:icon.user />
<flux:icon.user-group />
<flux:icon.users />
{{-- Content --}}
<flux:icon.newspaper />
<flux:icon.document-text />
<flux:icon.folder />
{{-- Business --}}
<flux:icon.building-office />
<flux:icon.credit-card />
<flux:icon.chart-bar />
{{-- Actions --}}
<flux:icon.pencil />
<flux:icon.trash />
<flux:icon.eye />
<flux:icon.plus />
{{-- Status --}}
<flux:icon.check-circle />
<flux:icon.x-circle />
<flux:icon.exclamation-triangle />
{{-- Navigation --}}
<flux:icon.arrow-right />
<flux:icon.chevron-down />
<flux:icon.bars-3 />
```
Vollständige Liste: https://heroicons.com
---
## 📦 Modal / Dialogs
### Einfaches Modal
```blade
<flux:modal name="confirm-delete" variant="danger">
<flux:modal.content>
<flux:heading>Bestätigung</flux:heading>
<flux:text>Möchten Sie diesen Eintrag wirklich löschen?</flux:text>
</flux:modal.content>
<flux:modal.footer>
<flux:button variant="ghost" wire:click="$set('showModal', false)">
Abbrechen
</flux:button>
<flux:button variant="danger" wire:click="delete">
Löschen
</flux:button>
</flux:modal.footer>
</flux:modal>
```
---
## 🎨 Layout-Komponenten
### Main Container
```blade
<flux:main>
<flux:heading size="xl">Seitentitel</flux:heading>
{{-- Inhalt --}}
</flux:main>
```
### Grid Layout
```blade
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<flux:card>...</flux:card>
<flux:card>...</flux:card>
<flux:card>...</flux:card>
<flux:card>...</flux:card>
</div>
```
### Spacer
```blade
<flux:spacer />
```
---
## 📊 Statistik-Cards (Beispiel)
```blade
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('Gesamt') }}
</flux:text>
<flux:text size="xl" weight="bold">1,234</flux:text>
</div>
<flux:icon.users class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('Aktiv') }}
</flux:text>
<flux:text size="xl" weight="bold">890</flux:text>
</div>
<flux:icon.check-circle class="size-8 text-green-500" />
</div>
</flux:card>
</div>
```
---
## 🎯 Best Practices
### 1. Immer mit Card-Wrapper
```blade
<flux:card class="overflow-hidden">
<flux:table>
{{-- Tabellen-Inhalt --}}
</flux:table>
</flux:card>
```
### 2. Empty States mit Icons
```blade
@empty
<flux:tr>
<flux:td colspan="7">
<div class="flex flex-col items-center justify-center py-12">
<flux:icon.inbox class="size-12 text-zinc-400 dark:text-zinc-600" />
<flux:text class="mt-4 text-zinc-500">
{{ __('Keine Einträge gefunden') }}
</flux:text>
</div>
</flux:td>
</flux:tr>
@endforelse
```
### 3. Responsive Actions
```blade
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 gap-4">
{{-- Filter --}}
</div>
<flux:button icon="plus">Neu</flux:button>
</div>
```
### 4. Livewire Wire:navigate
```blade
<flux:button href="{{ route('admin.users') }}" wire:navigate>
Benutzer
</flux:button>
```
### 5. Dark Mode Support
Flux unterstützt automatisch Dark Mode mit `dark:` Klassen:
```blade
<div class="bg-white dark:bg-zinc-800">
<flux:text class="text-zinc-900 dark:text-zinc-100">Text</flux:text>
</div>
```
---
## 🔗 Ressourcen
- **Flux UI Docs:** https://fluxui.dev/docs
- **Hero Icons:** https://heroicons.com
- **Tailwind CSS:** https://tailwindcss.com/docs
- **Livewire:** https://livewire.laravel.com
---
## ✅ Korrektur-Checkliste
Beim Erstellen neuer Views prüfen:
- [ ] `<flux:table>` als Wrapper
- [ ] `<flux:table.columns>` für Header
- [ ] `<flux:table.column>` für einzelne Spalten
- [ ] `<flux:table.rows>` für Body
- [ ] `<flux:table.row :key="...">` für Zeilen (mit key!)
- [ ] `<flux:table.cell>` für Zellen
- [ ] Icons mit `flux:icon.{name}` Syntax
- [ ] Dark Mode Klassen wo nötig
- [ ] Responsive Klassen (`sm:`, `lg:`)
- [ ] `wire:navigate` bei internen Links
---
**Letztes Update:** 23. Januar 2026
**Version:** Flux UI v2.x
**Status:** ✅ Alle Views korrigiert

View file

@ -0,0 +1,193 @@
# Admin Backend - Strukturübersicht
## 📁 Ordnerstruktur
```
resources/views/admin/
├── README.md (diese Datei)
├── dashboard.blade.php (✅ vorhanden)
├── press-releases/
│ └── index.blade.php (✅ erstellt)
├── companies/
│ └── index.blade.php (✅ erstellt)
├── contacts/
│ └── index.blade.php (⏳ TODO)
├── invoices/
│ └── index.blade.php (✅ erstellt)
├── payments/
│ └── index.blade.php (⏳ TODO)
├── coupons/
│ └── index.blade.php (⏳ TODO)
├── users/
│ └── index.blade.php (⏳ TODO)
├── roles/
│ └── index.blade.php (⏳ TODO)
├── categories/
│ └── index.blade.php (⏳ TODO)
├── scheduler/
│ └── index.blade.php (⏳ TODO)
└── newsletter/
└── index.blade.php (⏳ TODO)
```
## 🎯 Migrations-Projekt Kontext
Diese Backend-Struktur ist Teil der **BusinessPortal24 → Laravel 12 Migration**.
**Migrations-Dokumentation:** `/var/www/html/_businessportal24.com/dev/migration/`
### Wichtige Dokumente:
- `CHECKLIST.md` - Alle Aufgaben
- `PROGRESS.md` - Verlaufsprotokoll
- `IMPLEMENTATION.md` - Schritt-für-Schritt Anleitung
- `API-MIGRATION.md` - API-Details
- `DATA-MIGRATION.md` - Daten-Migration
## 📋 Aktueller Stand
### ✅ Erstellt (23.01.2026)
1. **Navigation (Sidebar)**
- Datei: `resources/views/components/layouts/app/sidebar.blade.php`
- Gruppen: Content, CRM, Billing, Administration, System
- Icons: Hero Icons
2. **Admin-Views**
- Press Releases Index
- Companies Index
- Invoices Index
3. **Livewire-Komponenten**
- `livewire/admin/press-releases/index.blade.php`
- `livewire/admin/companies/index.blade.php`
- `livewire/admin/invoices/index.blade.php`
### ⏳ Nächste Schritte
1. **Routes erstellen** (routes/web.php)
```php
Route::prefix('admin')->middleware('auth')->group(function () {
Route::get('/press-releases', ...)->name('admin.press-releases.index');
Route::get('/companies', ...)->name('admin.companies.index');
Route::get('/invoices', ...)->name('admin.invoices.index');
// ... weitere Routes
});
```
2. **Weitere Index-Views erstellen**
- Contacts
- Payments
- Coupons
- Categories
- Users (erweitern)
- Roles & Permissions
3. **CRUD-Views erstellen**
- create.blade.php
- edit.blade.php
- show.blade.php
4. **Models & Services implementieren**
- Nach DATA-MIGRATION.md
- Domain-Struktur: `app/Domain/{PressRelease,Company,Billing,...}/Models/`
## 🎨 Design-System
### FluxUI Komponenten
Alle Views nutzen **FluxUI** Komponenten:
- `<flux:card>` - Container
- `<flux:table>` - Tabellen
- `<flux:button>` - Buttons
- `<flux:badge>` - Status-Badges
- `<flux:input>` - Eingabefelder
- `<flux:select>` - Dropdowns
### Icons (Hero Icons)
Verwendete Icons:
- `newspaper` - Pressemitteilungen
- `building-office` - Firmen
- `document-text` - Rechnungen
- `user-group` - Kontakte/Users
- `credit-card` - Zahlungen
- `folder` - Kategorien
## 💾 Dummy-Daten
**Wichtig:** Alle aktuellen Komponenten verwenden Dummy-Daten (`collect([...])`).
Nach der Migration werden diese ersetzt durch:
```php
use App\Domain\PressRelease\Models\PressRelease;
$pressReleases = PressRelease::query()
->with(['company', 'category', 'images'])
->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
->paginate(20);
```
## 🔄 Migration-Workflow
1. **Phase 1:** Backend-Struktur anlegen ✅ **(AKTUELL)**
2. Phase 2: Laravel Models erstellen
3. Phase 3: Services implementieren
4. Phase 4: Dummy-Daten durch echte Models ersetzen
5. Phase 5: API-Integration
6. Phase 6: Daten-Migration durchführen
7. Phase 7: Testing
## 📝 Namenskonventionen
### Routes
```
admin.{resource}.index - Liste
admin.{resource}.create - Formular (neu)
admin.{resource}.store - Speichern (POST)
admin.{resource}.show - Details
admin.{resource}.edit - Formular (bearbeiten)
admin.{resource}.update - Aktualisieren (PUT)
admin.{resource}.destroy - Löschen (DELETE)
```
### Livewire-Komponenten
```
livewire/admin/{resource}/index.blade.php
livewire/admin/{resource}/create.blade.php
livewire/admin/{resource}/edit.blade.php
```
### Admin-Views
```
admin/{resource}/index.blade.php
admin/{resource}/create.blade.php
admin/{resource}/edit.blade.php
admin/{resource}/show.blade.php
```
## 🚀 Development-Server starten
```bash
# In Terminal 1: Laravel
php artisan serve
# In Terminal 2: Vite
npm run dev
# Backend öffnen:
http://presseportale.test/admin/press-releases
```
## 📚 Weitere Ressourcen
- **FluxUI Docs:** https://fluxui.dev/docs
- **Livewire Volt:** https://livewire.laravel.com/docs/volt
- **Laravel 12:** https://laravel.com/docs/12.x
- **Hero Icons:** https://heroicons.com
---
**Stand:** 23. Januar 2026
**Status:** Backend-Struktur Initial Setup ✅
**Nächster Schritt:** Routes erstellen und weitere Index-Views implementieren

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Kategorien') }}</flux:heading>
@livewire('admin.categories.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Neue Firma') }}</flux:heading>
@livewire('admin.companies.create')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Firma bearbeiten') }}</flux:heading>
@livewire('admin.companies.edit', ['companyId' => request()->route('company')])
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Firmen') }}</flux:heading>
@livewire('admin.companies.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,11 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
@livewire('admin.companies.show', ['companyId' => request()->route('company')])
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Kontakte') }}</flux:heading>
@livewire('admin.contacts.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Gutscheine') }}</flux:heading>
@livewire('admin.coupons.index')
</flux:main>
</x-layouts.app>

View file

@ -1,18 +1,110 @@
<x-layouts.app title="Dashboard">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 p-4">
<livewire:notifications />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
<div class="space-y-6">
{{-- Statistik-Karten --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<a href="{{ route('admin.press-releases.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('PMs gesamt') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['press_releases']['total']) }}</p>
<div class="mt-2 flex gap-2 text-xs text-zinc-400">
<span class="text-green-600">{{ $stats['press_releases']['published'] }} pub</span>
<span class="text-yellow-600">{{ $stats['press_releases']['review'] }} prüf</span>
<span>{{ $stats['press_releases']['draft'] }} entwurf</span>
</div>
</div>
</a>
<a href="{{ route('admin.companies.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Firmen') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['companies']) }}</p>
</div>
</a>
<a href="{{ route('admin.contacts.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Kontakte') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['contacts']) }}</p>
</div>
</a>
<a href="{{ route('admin.users.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Benutzer') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['users']) }}</p>
</div>
</a>
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Newsletter') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['newsletter']) }}</p>
<p class="mt-2 text-xs text-zinc-400">{{ __('bestätigt') }}</p>
</div>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
<div class="grid gap-6 lg:grid-cols-[1fr,360px]">
{{-- Letzte Pressemitteilungen --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<h2 class="font-semibold">{{ __('Letzte Pressemitteilungen') }}</h2>
<a href="{{ route('admin.press-releases.index') }}" wire:navigate class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ __('Alle anzeigen') }}</a>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($recentPRs as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">{{ $pr->company?->name ?? '' }} · {{ $pr->created_at->format('d.m.Y') }}</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {{ match($pr->status->value) {
'published' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'review' => 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
'rejected' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
'archived' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
default => 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300',
} }}">
{{ $pr->status->label() }}
</span>
</a>
@empty
<p class="px-4 py-6 text-center text-sm text-zinc-500">{{ __('Noch keine Pressemitteilungen.') }}</p>
@endforelse
</div>
</div>
{{-- Warteschlange Prüfung --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<h2 class="font-semibold">{{ __('Zur Prüfung') }}</h2>
@if($stats['press_releases']['review'] > 0)
<span class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ $stats['press_releases']['review'] }}
</span>
@endif
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($pendingReviews as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="block px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">
{{ $pr->company?->name ?? '' }} · {{ $pr->portal->label() }} · {{ $pr->created_at->format('d.m.Y') }}
</p>
</a>
@empty
<p class="px-4 py-6 text-center text-sm text-zinc-500">{{ __('Keine PMs in der Prüfwarteschlange.') }}</p>
@endforelse
</div>
@if($stats['press_releases']['review'] > count($pendingReviews))
<div class="border-t border-zinc-100 px-4 py-2 dark:border-zinc-800">
<a href="{{ route('admin.press-releases.index', ['statusFilter' => 'review']) }}" wire:navigate
class="text-xs text-blue-600 hover:underline dark:text-blue-400">
+ {{ $stats['press_releases']['review'] - count($pendingReviews) }} {{ __('weitere') }}
</a>
</div>
@endif
</div>
</div>
</div>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Rechnungen') }}</flux:heading>
@livewire('admin.invoices.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Zahlungen') }}</flux:heading>
@livewire('admin.payments.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Neue Pressemitteilung') }}</flux:heading>
@livewire('admin.press-releases.create')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
@livewire('admin.press-releases.edit', ['pressReleaseId' => request()->route('pressRelease')])
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Pressemitteilungen') }}</flux:heading>
@livewire('admin.press-releases.index')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,11 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
@livewire('admin.press-releases.show', ['pressReleaseId' => request()->route('pressRelease')])
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Neue Rolle erstellen') }}</flux:heading>
@livewire('admin.roles.create')
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,13 @@
<x-layouts.app>
<flux:main>
<div class="mb-6">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</div>
<flux:heading size="xl" class="mb-6">{{ __('Rolle bearbeiten') }}</flux:heading>
@livewire('admin.roles.edit', ['roleId' => request()->route('role')])
</flux:main>
</x-layouts.app>

View file

@ -0,0 +1,7 @@
<x-layouts.app>
<flux:main>
<flux:heading size="xl" class="mb-6">{{ __('Rollen & Rechte') }}</flux:heading>
@livewire('admin.roles.index')
</flux:main>
</x-layouts.app>

View file

@ -2,5 +2,5 @@
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ms-1 grid flex-1 text-start text-sm">
<span class="mb-0.5 truncate leading-none font-semibold">pr-copilot</span>
<span class="mb-0.5 truncate leading-none font-semibold">{{ config('app.name') }}</span>
</div>

View file

@ -1,5 +1,32 @@
<x-layouts.app.sidebar :title="$title ?? null">
<flux:main>
@php
$user = auth()->user();
$canCustomer = $user?->canAccessCustomer() ?? false;
@endphp
@if($canCustomer)
<div class="mb-6 rounded-xl border border-zinc-200 bg-zinc-50/80 px-4 py-3 shadow-sm ring-1 ring-zinc-950/5 dark:border-zinc-700 dark:bg-zinc-900/60 dark:ring-white/10">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<flux:badge color="zinc" size="sm">{{ __('User Backend') }}</flux:badge>
<flux:text class="hidden text-xs text-zinc-500 dark:text-zinc-400 sm:block">
{{ __('Firmenkontext') }}
</flux:text>
</div>
<flux:heading size="sm" class="mt-1 truncate">
{{ $title ?? __('Mein Bereich') }}
</flux:heading>
</div>
<div class="w-full sm:w-auto">
<livewire:customer.company-switcher />
</div>
</div>
</div>
@endif
{{ $slot }}
</flux:main>
</x-layouts.app.sidebar>

View file

@ -10,34 +10,169 @@
<x-app-logo />
</a>
@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 variant="outline">
<flux:navlist.group :heading="__('Trader')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
</flux:navlist.group>
{{-- 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:navlist.group :heading="__('Customer')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
</flux:navlist.group>
<flux:navlist.group :heading="__('Admin')" class="grid mb-4">
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Users') }}</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.users.table')" :current="request()->routeIs('admin.users.table')" wire:navigate>{{ __('Users Table') }}</flux:navlist.item>
<flux:navlist.group expandable expanded="false" heading="Favorites" class="hidden lg:grid">
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Users') }}</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('admin.users.table')" :current="request()->routeIs('admin.users.table')" wire:navigate>{{ __('Users Table') }}</flux:navlist.item>
{{-- 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>
</flux:navlist.group>
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
</flux:navlist.group>
<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.group :heading="__('Superadmin')" class="grid mb-4">
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
</flux:navlist.group>
</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)
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-100">
<flux:text weight="semibold">{{ __('Testmodus aktiv') }}</flux:text>
<flux:text class="mt-1 text-xs">
{{ __('Angemeldet als :user. Admin: :admin.', ['user' => $user?->name, 'admin' => $impersonator->name]) }}
</flux:text>
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
@csrf
<flux:button type="submit" size="sm" variant="primary" class="w-full">
{{ __('Zurück zum Admin') }}
</flux:button>
</form>
</div>
@endif
<flux:spacer />
<flux:navlist variant="outline">

View file

@ -0,0 +1,62 @@
@props([
'companies' => [],
])
@php
$palette = ['#0098A6', '#21A038', '#0018A8', '#1E1E1E', '#E20074', '#003781', '#C84A1E', '#5A6E2F'];
$companies = collect($companies)->take(6)->values();
if ($companies->isEmpty()) {
$companies = collect([
['initial' => 'S', 'name' => 'Siemens AG', 'count' => 18, 'color' => '#0098A6'],
['initial' => 'B', 'name' => 'BASF SE', 'count' => 14, 'color' => '#21A038'],
['initial' => 'D', 'name' => 'Deutsche Bank', 'count' => 11, 'color' => '#0018A8'],
['initial' => 'V', 'name' => 'Volkswagen AG', 'count' => 16, 'color' => '#1E1E1E'],
['initial' => 'T', 'name' => 'Deutsche Telekom', 'count' => 9, 'color' => '#E20074'],
['initial' => 'A', 'name' => 'Allianz SE', 'count' => 8, 'color' => '#003781'],
]);
}
@endphp
<section>
<header class="flex items-baseline justify-between mb-3.5 min-h-[34px]">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] leading-[1.2] text-ink">Aktive Newsrooms</h2>
<span class="eyebrow muted text-[10px]">Heute</span>
</header>
<hr class="rule-strong mb-1">
@foreach ($companies as $index => $company)
@php
$initial = $company['initial'] ?? strtoupper(mb_substr($company['name'] ?? '?', 0, 1));
$name = $company['name'] ?? '';
$count = (int) ($company['count'] ?? 0);
$color = $company['color'] ?? $palette[$index % count($palette)];
$slug = $company['slug'] ?? null;
$href = $slug ? url('/newsroom/'.$slug) : route('newsrooms');
$isLast = $loop->last;
@endphp
<a href="{{ $href }}"
class="grid items-center gap-3 py-3 {{ $isLast ? '' : 'border-b border-bg-rule' }} cursor-pointer hover:bg-bg-elev transition-colors group"
style="grid-template-columns: 32px 1fr auto;">
<div class="w-8 h-8 text-white flex items-center justify-center text-[14px] font-bold font-serif"
style="background: {{ $color }};">
{{ $initial }}
</div>
<div class="min-w-0">
<div class="text-[13.5px] font-semibold leading-[1.2] text-ink truncate group-hover:text-brand transition-colors">{{ $name }}</div>
<div class="text-[11px] text-ink-3 mt-0.5">
<span class="font-mono">{{ $count }}</span> Meldungen letzte 7 Tage
</div>
</div>
<span class="inline-flex items-center gap-1.5 text-[10.5px] font-semibold text-ink-2 px-2 py-0.5 bg-brand/[0.06] border border-brand/20 whitespace-nowrap rounded-[2px]">
<span class="w-1.5 h-1.5 rounded-full bg-brand pulse-dot"></span>
heute aktiv
</span>
</a>
@endforeach
<a href="{{ route('newsrooms') }}" class="block mt-3 text-[12px] text-brand font-semibold cursor-pointer hover:text-brand-deep transition-colors">
Alle Newsrooms anzeigen
</a>
</section>

View file

@ -0,0 +1,39 @@
@props([
'article',
])
<article class="card card-hover overflow-hidden group cursor-pointer">
<div class="relative h-48 overflow-hidden bg-zinc-100 dark:bg-zinc-800">
<img src="{{ $article['image'] }}" alt="{{ $article['title'] }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
<div class="absolute top-3 left-3">
<span class="badge badge-{{ $article['badgeType'] ?? 'primary' }} shadow-md text-xs">
{{ $article['badge'] }}
</span>
</div>
</div>
<div class="p-5">
<div class="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400 mb-3">
<span class="badge badge-{{ $article['categoryBadgeType'] ?? 'secondary' }} text-xs">
{{ $article['category'] }}
</span>
<span></span>
<time datetime="{{ $article['date'] }}">{{ $article['dateFormatted'] }}</time>
</div>
<h3 class="text-lg font-bold text-zinc-900 dark:text-zinc-100 mb-2 group-hover:text-[var(--color-primary)] transition-colors line-clamp-2">
{{ $article['title'] }}
</h3>
<p class="text-sm text-zinc-700 dark:text-zinc-300 line-clamp-3 mb-4">
{{ $article['teaser'] }}
</p>
@if(isset($article['author']))
<div class="flex items-center gap-2 text-xs">
<div class="w-6 h-6 rounded-full bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-secondary)] flex items-center justify-center text-white font-semibold text-[10px]">
{{ $article['authorInitials'] }}
</div>
<span class="text-zinc-600 dark:text-zinc-400">{{ $article['author'] }}</span>
</div>
@endif
</div>
</article>

View file

@ -0,0 +1,34 @@
@props([
'items' => [],
])
@if(count($items) > 0)
<nav aria-label="Breadcrumb" class="py-4">
<ol class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<li>
<a href="/" class="hover:text-[var(--color-primary)] transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
</a>
</li>
@foreach($items as $index => $item)
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
@if($index === count($items) - 1)
<span class="text-zinc-900 dark:text-zinc-100 font-medium">{{ $item['label'] }}</span>
@else
<a href="{{ $item['url'] }}" class="hover:text-[var(--color-primary)] transition-colors">
{{ $item['label'] }}
</a>
@endif
</li>
@endforeach
</ol>
</nav>
@endif

View file

@ -0,0 +1,30 @@
@props([
'categories',
])
<section class="rounded-2xl border border-black/10 bg-[#f7f4ef] p-5 dark:border-white/10 dark:bg-zinc-950">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-[#b6332a]">Branchen</p>
<h2 class="mt-2 text-lg font-semibold text-zinc-950 dark:text-white">Zugänge nach Themen</h2>
</div>
<a href="{{ route('kategorien') }}" class="text-sm font-semibold text-[#cf3628] hover:text-[#a92c25]">Alle</a>
</div>
@if ($categories->isNotEmpty())
<div class="mt-5 flex flex-col gap-2">
@foreach ($categories as $category)
<a href="{{ $category['url'] }}" class="group flex items-center justify-between gap-4 rounded-xl border border-black/5 bg-white px-4 py-3 text-sm transition hover:border-[#cf3628]/30 dark:border-white/10 dark:bg-zinc-900">
<span class="font-medium text-zinc-800 group-hover:text-[#cf3628] dark:text-zinc-100">{{ $category['name'] }}</span>
@if ($category['count'] > 0)
<span class="rounded-full bg-[#cf3628]/10 px-2.5 py-1 text-xs font-semibold text-[#a92c25]">{{ $category['count'] }}</span>
@endif
</a>
@endforeach
</div>
@else
<p class="mt-5 rounded-xl border border-dashed border-black/10 bg-white px-4 py-4 text-sm leading-6 text-zinc-600 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300">
Branchenzugänge werden angezeigt, sobald aktive Kategorien mit deutscher Übersetzung vorhanden sind.
</p>
@endif
</section>

View file

@ -0,0 +1,37 @@
@props([
'sidebar' => null,
'sidebarPosition' => 'right', // left or right
])
<div class="container mx-auto px-4 py-8">
@if ($sidebar)
<div class="grid lg:grid-cols-12 gap-8">
@if ($sidebarPosition === 'left')
<!-- Sidebar Left -->
<aside class="lg:col-span-4">
{{ $sidebar }}
</aside>
<!-- Main Content -->
<main class="lg:col-span-8">
{{ $slot }}
</main>
@else
<!-- Main Content -->
<main class="lg:col-span-8">
{{ $slot }}
</main>
<!-- Sidebar Right -->
<aside class="lg:col-span-4">
{{ $sidebar }}
</aside>
@endif
</div>
@else
<!-- Full Width Content -->
<main class="max-w-4xl mx-auto">
{{ $slot }}
</main>
@endif
</div>

View file

@ -0,0 +1,23 @@
@props([
'dossier',
])
<article class="card overflow-hidden group cursor-pointer bg-white dark:bg-zinc-900 shadow-lg hover:shadow-2xl transition-all duration-300">
<div class="relative h-56 overflow-hidden">
<img src="{{ $dossier['image'] }}" alt="{{ $dossier['title'] }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-5">
<span class="inline-block badge bg-white text-[var(--color-primary)] border-0 mb-3 shadow-md">
{{ $dossier['articleCount'] }} Artikel
</span>
<h3 class="text-xl font-bold text-white mb-2 group-hover:text-[var(--color-secondary)] transition-colors">
{{ $dossier['title'] }}
</h3>
<p class="text-sm text-white/90 line-clamp-2">
{{ $dossier['description'] }}
</p>
</div>
</div>
</article>

View file

@ -0,0 +1,76 @@
@props([
'events' => null,
])
@php
$events ??= [
['day' => 'Heute', 'date' => '06. Mai', 'type' => 'Quartalszahlen', 'company' => 'SAP SE', 'time' => '07:00', 'note' => 'Q1 2026', 'today' => true, 'highlighted' => true],
['day' => 'Heute', 'date' => '06. Mai', 'type' => 'Hauptversammlung', 'company' => 'Allianz SE', 'time' => '10:00', 'note' => 'München', 'today' => true],
['day' => 'Morgen', 'date' => '07. Mai', 'type' => 'Pressekonferenz', 'company' => 'Volkswagen AG', 'time' => '09:30', 'note' => 'Strategie 2030', 'highlighted' => true],
['day' => 'Morgen', 'date' => '07. Mai', 'type' => 'Quartalszahlen', 'company' => 'Deutsche Telekom', 'time' => '07:00', 'note' => 'Q1 2026'],
['day' => 'Do', 'date' => '08. Mai', 'type' => 'Konferenz', 'company' => 'Hannover Messe', 'time' => 'Ganztägig', 'note' => 'Industrie 4.0', 'ad' => true],
['day' => 'Fr', 'date' => '09. Mai', 'type' => 'Hauptversammlung', 'company' => 'BMW Group', 'time' => '10:00', 'note' => 'München'],
['day' => 'Mo', 'date' => '12. Mai', 'type' => 'Quartalszahlen', 'company' => 'Siemens AG', 'time' => '07:00', 'note' => 'Q2 2026', 'highlighted' => true],
];
@endphp
<section class="max-w-layout mx-auto px-8 mt-16">
<header class="flex items-baseline justify-between mb-4 flex-wrap gap-3">
<div class="flex items-baseline gap-3.5 flex-wrap">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] text-ink">Termine &amp; Events</h2>
<span class="eyebrow muted">Diese Woche im Überblick</span>
</div>
<div class="flex items-center gap-4 text-[12px] flex-wrap">
<button type="button" class="inline-flex items-center gap-1.5 text-ink-2 cursor-pointer hover:text-ink transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 7h18M3 12h18M3 17h12" stroke="currentColor" stroke-width="2" />
</svg>
Alle Branchen
</button>
<a href="#" class="inline-flex items-center gap-1.5 text-ink-2 cursor-pointer hover:text-ink transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M3 9h18M8 3v4M16 3v4" stroke="currentColor" stroke-width="2" />
</svg>
iCal abonnieren
</a>
<a href="{{ route('kategorien') }}#termine" class="text-brand font-semibold cursor-pointer hover:text-brand-deep transition-colors">Alle Termine </a>
</div>
</header>
<hr class="rule-strong">
<div class="grid border-b border-bg-rule grid-cols-2 md:grid-cols-4 lg:grid-cols-7">
@foreach ($events as $idx => $event)
@php
$isLastInRow = ($idx + 1) % 7 === 0;
@endphp
<a href="#"
class="block p-4 pb-[18px] relative border-b lg:border-b-0 border-bg-rule {{ $isLastInRow ? '' : 'border-r' }} cursor-pointer hover:bg-bg-elev transition-colors group"
@if($event['highlighted'] ?? false) style="background:rgba(204,55,51,0.025);" @endif>
@if ($event['highlighted'] ?? false)
<span class="absolute top-3.5 right-3.5 w-1.5 h-1.5 rounded-full bg-brand pulse-dot"></span>
@endif
<div class="flex items-baseline gap-1.5 mb-2.5 flex-wrap">
@if ($event['today'] ?? false)
<span class="eyebrow font-bold">{{ $event['day'] }}</span>
@else
<span class="text-[11px] font-bold tracking-[0.12em] uppercase text-ink-3">{{ $event['day'] }}</span>
@endif
<span class="text-[10.5px] text-ink-3 font-mono">{{ $event['date'] }}</span>
</div>
<div class="text-[10.5px] tracking-[0.06em] uppercase font-bold text-ink-2 mb-1.5">{{ $event['type'] }}</div>
<div class="font-serif text-[15px] font-semibold leading-[1.25] mb-2 text-ink group-hover:text-brand transition-colors">{{ $event['company'] }}</div>
<div class="flex items-center gap-1.5 text-[11px] text-ink-3 flex-wrap">
<span class="font-mono">{{ $event['time'] }}</span>
@if ($event['note'] ?? null)
<span aria-hidden="true">·</span>
<span>{{ $event['note'] }}</span>
@endif
</div>
@if ($event['ad'] ?? false)
<div class="mt-2 text-[9.5px] tracking-[0.08em] uppercase font-semibold text-ink-4">Anzeige</div>
@endif
</a>
@endforeach
</div>
</section>

View file

@ -0,0 +1,52 @@
@props(['ad'])
@php
$time = $ad['time'] ?? '';
$date = $ad['date'] ?? '';
$category = $ad['category'] ?? 'Anzeige';
$title = $ad['title'] ?? '';
$company = $ad['company'] ?? '';
$href = $ad['href'] ?? '#';
$catClass = 'self-start whitespace-nowrap text-[9.5px] font-bold uppercase leading-snug tracking-[0.14em] text-card-warm-cat max-md:mt-1 max-md:text-[9px] max-md:tracking-[0.1em]';
@endphp
<div class="-mx-4 my-2">
<div class="flex items-center gap-2.5 px-4">
<span class="h-px flex-1 bg-bg-card-warm-rule"></span>
<span class="text-[9px] font-bold uppercase tracking-[0.22em] text-ink-on-dark-muted">Anzeige</span>
<span class="h-px flex-1 bg-bg-card-warm-rule"></span>
</div>
<a href="{{ $href }}" rel="sponsored nofollow" @class([
'grid items-start bg-bg-card-warm py-4 pl-1.5 pr-4 sm:pl-2',
'cursor-pointer transition-colors hover:bg-bg-card-warm-hover',
'max-md:grid-cols-[auto_minmax(0,1fr)] max-md:gap-x-3',
'md:grid-cols-[3.75rem_6.25rem_minmax(0,1fr)] md:gap-x-4',
])>
<div class="flex min-w-0 shrink-0 flex-col gap-0.5 tabular-nums pt-0.5">
@if ($time !== '')
<span class="font-mono text-[11px] leading-none text-card-warm-cat sm:text-[12px]">{{ $time }}</span>
@endif
@if ($date !== '')
<span class="text-[9px] leading-tight text-ink-4 sm:text-[10px]">{{ $date }}</span>
@endif
<span class="{{ $catClass }} md:hidden">{{ $category }}</span>
</div>
<span class="{{ $catClass }} hidden md:inline">{{ $category }}</span>
<div class="min-w-0">
<div class="font-serif text-[15.5px] font-medium leading-[1.3] text-card-warm-title">
{{ $title }}
</div>
@if ($company)
<div class="mt-1 text-[11px] text-card-warm-cat">{{ $company }}</div>
@endif
</div>
</a>
<div class="flex items-center gap-2.5 px-4">
<span class="h-px flex-1 bg-bg-card-warm-rule"></span>
<span class="text-[9px] font-bold uppercase tracking-[0.22em] text-ink-on-dark-muted">Ende Anzeige</span>
<span class="h-px flex-1 bg-bg-card-warm-rule"></span>
</div>
</div>

View file

@ -0,0 +1,93 @@
@props([
'release' => null,
'mock' => null,
'recommended' => false,
])
@php
\Carbon\Carbon::setLocale('de');
if ($release) {
$category = $release?->category?->translations?->firstWhere('locale', 'de')
?? $release?->category?->translations?->first();
$categoryName = $category?->name ?? 'Wirtschaft';
$company = $release?->company?->name ?? 'Unternehmen';
$city = $release?->company?->city ?? null;
$title = $release->title;
$href = route('release.detail', ['slug' => $release->slug]);
$recommended = $recommended;
$published = $release->published_at;
if ($published) {
$timeLine = $published->format('H:i');
if ($published->isToday()) {
$dateLine = $published->translatedFormat('j. MMM');
} elseif ($published->isYesterday()) {
$dateLine = 'Gestern';
} else {
$dateLine = $published->translatedFormat('j. MMM');
}
$datetimeAttr = $published->toIso8601String();
} else {
$timeLine = '—';
$dateLine = '';
$datetimeAttr = null;
}
} else {
$mock ??= [];
$categoryName = $mock['category'] ?? 'Wirtschaft';
$company = $mock['company'] ?? '';
$city = $mock['city'] ?? null;
$title = $mock['title'] ?? '';
$href = $mock['href'] ?? '#';
$timeLine = $mock['time'] ?? '';
$dateLine = $mock['date'] ?? '';
$datetimeAttr = null;
$recommended = $recommended || ($mock['recommended'] ?? false);
}
$bpCatClass = 'bp-cat self-start whitespace-nowrap leading-snug max-md:mt-1 max-md:text-[9px] max-md:tracking-[0.1em]';
@endphp
<a href="{{ $href }}" @class([
'grid items-start py-3.5 border-b border-bg-rule cursor-pointer',
'hover:bg-bg-elev transition-colors group',
'pl-1.5 pr-2 sm:pl-2',
// <md: 2 Spalten — links Zeit/Datum/Rubrik, rechts Inhalt
'max-md:grid-cols-[auto_minmax(0,1fr)] max-md:gap-x-3',
// md+: 3 Spalten wie Desktop-Mockup
'md:grid-cols-[3.75rem_6.25rem_minmax(0,1fr)] md:gap-x-4',
])>
<div class="flex min-w-0 shrink-0 flex-col gap-0.5 tabular-nums pt-0.5">
@if ($datetimeAttr)
<time datetime="{{ $datetimeAttr }}" class="font-mono text-[11px] leading-none text-ink-3 sm:text-[12px]">{{ $timeLine }}</time>
@else
<span class="font-mono text-[11px] leading-none text-ink-3 sm:text-[12px]">{{ $timeLine }}</span>
@endif
@if ($dateLine !== '')
<span class="text-[9px] leading-tight text-ink-4 sm:text-[10px]">{{ $dateLine }}</span>
@endif
{{-- Mobil: Rubrik unter dem Datum in derselben Spalte --}}
<span class="{{ $bpCatClass }} md:hidden">{{ $categoryName }}</span>
</div>
{{-- Desktop: Rubrik in eigener Spalte -- span nur sichtbar ab md, um Tab-Reihenfolge / SR konsistent zu halten --}}
<span class="{{ $bpCatClass }} hidden md:inline">{{ $categoryName }}</span>
<div class="min-w-0">
<div class="font-serif text-[15.5px] leading-[1.3] font-medium text-ink transition-colors group-hover:text-brand flex flex-wrap items-baseline gap-2">
<span>{{ $title }}</span>
@if ($recommended)
<span class="inline-flex items-center gap-1 whitespace-nowrap border border-brand/30 bg-brand/[0.04] px-1.5 py-px font-mono text-[9.5px] font-bold uppercase tracking-[0.1em] text-brand">
<svg width="9" height="9" viewBox="0 0 12 12" class="flex-shrink-0" aria-hidden="true">
<path d="M6 1l1.5 3.2 3.5.4-2.6 2.4.7 3.5L6 8.8l-3.1 1.7.7-3.5L1 4.6l3.5-.4z" fill="currentColor" />
</svg>
Empfehlung
</span>
@endif
</div>
@if ($company)
<div class="mt-1 text-[11px] text-ink-3">
{{ $company }}@if ($city) · {{ $city }} @endif
</div>
@endif
</div>
</a>

View file

@ -0,0 +1,92 @@
@props([
'release' => null,
'mock' => null,
])
@php
\Carbon\Carbon::setLocale('de');
if ($release) {
$category = $release?->category?->translations?->firstWhere('locale', 'de')
?? $release?->category?->translations?->first();
$categoryName = $category?->name ?? 'Wirtschaft';
$image = $release?->images?->first();
$imageUrl = $image?->variantUrl('card') ?? $image?->url();
$teaser = (string) \Illuminate\Support\Str::of(strip_tags((string) ($release?->text ?? '')))->squish()->limit(180);
$company = $release?->company?->name ?? 'Unternehmen';
$title = $release->title;
$href = route('release.detail', ['slug' => $release->slug]);
$time = $release?->published_at?->isToday()
? $release->published_at->format('H:i')
: ($release?->published_at?->isYesterday() ? 'Gestern' : $release?->published_at?->isoFormat('D. MMM'));
$city = $release?->company?->city ?? null;
$readTime = max(1, (int) round(str_word_count(strip_tags((string) ($release?->text ?? ''))) / 200));
} else {
$mock ??= [];
$categoryName = $mock['category'] ?? 'Bauen · Immobilien';
$imageUrl = $mock['image'] ?? null;
$teaser = $mock['teaser'] ?? 'Nach 18 Monaten der Konsolidierung deuten Frühindikatoren auf eine Erholung hin. ImmoConsult-Studie analysiert 240 Märkte in DACH.';
$company = $mock['company'] ?? 'ImmoConsult Deutschland';
$title = $mock['title'] ?? 'Immobilienmarkt 2025: Experten prognostizieren Trendwende bei Kaufpreisen';
$href = $mock['href'] ?? '#';
$time = $mock['time'] ?? '14:18';
$city = $mock['city'] ?? 'München';
$readTime = $mock['readTime'] ?? 5;
}
@endphp
<article class="py-6 border-b border-bg-rule grid gap-6 grid-cols-1 md:grid-cols-[240px_1fr] group">
<a href="{{ $href }}" class="relative overflow-hidden bg-feature-grad block cursor-pointer" style="width:100%; max-width:240px; height:160px;">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="{{ $title }}" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else
<svg width="100%" height="100%" viewBox="0 0 240 160" class="absolute inset-0" aria-hidden="true">
<g opacity="0.5">
<line x1="40" y1="55" x2="40" y2="150" stroke="var(--color-feature-line)" stroke-width="1.8" />
<circle cx="40" cy="55" r="3" fill="var(--color-feature-dot)" />
</g>
<g opacity="0.65">
<line x1="100" y1="55" x2="100" y2="150" stroke="var(--color-feature-line)" stroke-width="1.8" />
<circle cx="100" cy="55" r="3" fill="var(--color-feature-dot)" />
</g>
<g opacity="0.8">
<line x1="160" y1="55" x2="160" y2="150" stroke="var(--color-feature-line)" stroke-width="1.8" />
<circle cx="160" cy="55" r="3" fill="var(--color-feature-dot)" />
</g>
<g opacity="0.95">
<line x1="210" y1="55" x2="210" y2="150" stroke="var(--color-feature-line)" stroke-width="1.8" />
<circle cx="210" cy="55" r="3" fill="var(--color-feature-dot)" />
</g>
</svg>
@endif
<span class="bp-tag absolute top-2.5 left-2.5">Top-Meldung</span>
</a>
<div>
<div class="flex justify-between items-baseline mb-2 flex-wrap gap-2">
<span class="bp-cat">{{ $categoryName }}</span>
<span class="font-mono text-[11.5px] text-ink-3">{{ $time }}</span>
</div>
<h3 class="font-serif text-[24px] leading-[1.2] m-0 font-semibold tracking-[-0.3px] text-ink">
<a href="{{ $href }}" class="cursor-pointer hover:text-brand transition-colors">{{ $title }}</a>
</h3>
@if ($teaser)
<p class="font-serif text-[14px] text-ink-2 leading-[1.5] mt-2.5">
{{ $teaser }}
</p>
@endif
<div class="mt-3 text-[11.5px] text-ink-3 flex items-center gap-1.5 flex-wrap">
<svg width="11" height="11" viewBox="0 0 11 11" class="text-ok flex-shrink-0" aria-hidden="true">
<circle cx="5.5" cy="5.5" r="5" fill="currentColor" />
<path d="M3 5.5l1.8 1.8L8 4" stroke="white" stroke-width="1.4" fill="none" stroke-linecap="round" />
</svg>
<span>{{ $company }}</span>
@if ($city)
<span aria-hidden="true">·</span>
<span>{{ $city }}</span>
@endif
<span aria-hidden="true">·</span>
<span>{{ $readTime }} Min. Lesezeit</span>
</div>
</div>
</article>

View file

@ -0,0 +1,50 @@
@props(['ad' => null])
@php
$ad ??= [
'category' => 'Logistik & Transport',
'title' => 'DHL Express erweitert Same-Day-Netz auf 24 deutsche Großstädte',
'company' => 'Deutsche Post DHL Group',
'gradient' => 'linear-gradient(135deg,#3A2818,#A88466)',
'href' => '#',
];
$category = $ad['category'] ?? 'Anzeige';
$title = $ad['title'] ?? 'Werbeplatz';
$company = $ad['company'] ?? '';
$gradient = $ad['gradient'] ?? 'linear-gradient(135deg,#3A2818,#A88466)';
$href = $ad['href'] ?? '#';
@endphp
<div class="mt-4">
<div class="flex items-center gap-2.5 mb-2.5">
<span class="h-px bg-bg-rule-strong flex-1"></span>
<span class="text-[9px] font-bold tracking-[0.22em] text-ink-on-dark-muted uppercase">Anzeige</span>
<span class="h-px bg-bg-rule-strong flex-1"></span>
</div>
<a href="{{ $href }}" rel="sponsored nofollow" class="block px-3.5 py-4 bg-bg-card-warm border border-bg-card-warm-border cursor-pointer hover:border-brand/50 hover:bg-bg-card-warm-hover transition-colors">
<div class="grid items-start gap-3" style="grid-template-columns: 64px 1fr;">
<div class="w-16 h-16 flex-shrink-0 relative overflow-hidden opacity-85" style="background: {{ $gradient }};"></div>
<div>
<div class="text-[9.5px] font-bold tracking-[0.16em] text-card-warm-cat uppercase mb-1">
{{ $category }}
</div>
<h3 class="font-serif text-[15.5px] leading-[1.3] m-0 font-medium text-card-warm-title">
{{ $title }}
</h3>
@if ($company)
<div class="text-[11.5px] mt-1.5 text-card-warm-cat">
{{ $company }}
</div>
@endif
</div>
</div>
</a>
<div class="flex items-center gap-2.5 mt-2.5">
<span class="h-px bg-bg-rule-strong flex-1"></span>
<span class="text-[9px] font-bold tracking-[0.22em] text-ink-on-dark-muted uppercase">Ende Anzeige</span>
<span class="h-px bg-bg-rule-strong flex-1"></span>
</div>
</div>

View file

@ -0,0 +1,129 @@
@props([
'leadRelease' => null,
'sideReleases' => [],
'advertisement' => null,
])
@php
\Carbon\Carbon::setLocale('de');
$leadCategory = $leadRelease?->category?->translations?->firstWhere('locale', 'de')
?? $leadRelease?->category?->translations?->first();
$leadCategoryName = $leadCategory?->name ?? 'KI & Innovation';
$leadImage = $leadRelease?->images?->first();
$leadImageUrl = $leadImage?->variantUrl('card') ?? $leadImage?->url();
$leadTitle = $leadRelease?->title ?? 'Die Zukunft der KI im deutschen Mittelstand';
$leadTeaser = $leadRelease
? (string) \Illuminate\Support\Str::of(strip_tags((string) $leadRelease->text))->squish()->limit(200)
: 'Im exklusiven Interview spricht Dr. Maria Schmidt, Leiterin des KI-Instituts München, über Chancen und Herausforderungen der KI für mittelständische Unternehmen.';
$leadCompany = $leadRelease?->company?->name ?? 'KI-Institut München';
$leadHref = $leadRelease ? route('release.detail', ['slug' => $leadRelease->slug]) : '#';
$leadReadTime = max(1, (int) round(str_word_count(strip_tags((string) ($leadRelease?->text ?? ''))) / 200)) ?: 5;
$leadPublishedAt = $leadRelease?->published_at;
$sideReleases = collect($sideReleases)->take(4)->values();
@endphp
<section class="max-w-layout mx-auto px-8 pt-8">
<header class="flex items-baseline justify-between mb-4 flex-wrap gap-2">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] text-ink">
Im Fokus
<span class="text-[16px] text-ink-3 font-normal ml-2">· 🇩🇪 Deutschland</span>
</h2>
<div class="eyebrow muted">{{ ucfirst(now()->isoFormat('dddd, HH:mm')) }} Uhr · Auswahl der Redaktion</div>
</header>
<hr class="rule-strong mb-6">
<div class="grid gap-9 lg:grid-cols-[1.7fr_1fr] grid-cols-1">
{{-- HERO --}}
<article>
<div class="relative">
<a href="{{ $leadHref }}" class="block group cursor-pointer">
<div class="relative overflow-hidden bg-hero-grad h-[500px]">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadTitle }}"
class="absolute inset-0 h-full w-full object-cover opacity-90 transition-opacity group-hover:opacity-100"
loading="eager">
<div class="absolute inset-0 bg-black/10"></div>
@else
<svg width="100%" height="100%" viewBox="0 0 760 500" class="absolute inset-0 opacity-40" aria-hidden="true">
<defs>
<pattern id="bp-hero-dots" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="20" cy="20" r="1.5" fill="white" opacity="0.3" />
</pattern>
</defs>
<rect width="760" height="500" fill="url(#bp-hero-dots)" />
<circle cx="600" cy="180" r="120" fill="white" opacity="0.05" />
<circle cx="160" cy="380" r="80" fill="white" opacity="0.06" />
</svg>
@endif
</div>
<div class="absolute top-6 left-6 flex gap-2">
<span class="bp-tag">TOP-MELDUNG</span>
<span class="bp-tag" style="background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);color:white;backdrop-filter:blur(4px);">
Audio · {{ $leadReadTime * 4 }} min
</span>
</div>
<div class="absolute left-0 right-0 bottom-0 px-9 pb-8 pt-[60px] text-white"
style="background:linear-gradient(180deg,transparent,rgba(0,0,0,0.92));">
<div class="eyebrow on-dark mb-3">{{ $leadCategoryName }}</div>
<h1 class="font-serif text-[38px] font-semibold m-0 leading-[1.1] tracking-[-0.7px] max-w-[680px]">
{{ $leadTitle }}
</h1>
<p class="font-serif text-[15px] leading-[1.5] mt-3.5 max-w-[580px] text-white/85">
{{ $leadTeaser }}
</p>
<div class="mt-4 flex items-center gap-3.5 text-[12px] text-white/65 flex-wrap">
<span>{{ $leadCompany }}</span>
<span aria-hidden="true">·</span>
@if ($leadPublishedAt)
<time datetime="{{ $leadPublishedAt->toIso8601String() }}" class="font-mono">
{{ $leadPublishedAt->format('d.m.Y · H:i') }}
</time>
<span aria-hidden="true">·</span>
@endif
<span>{{ $leadReadTime }} Min. Lesezeit</span>
</div>
</div>
</a>
</div>
<div class="flex items-center justify-between pt-3.5 px-1 gap-3">
<div class="flex gap-2 items-center" aria-label="Top-Meldung Paginierung">
<span class="block h-[3px] w-9 bg-bg-rule-strong relative overflow-hidden">
<span class="absolute inset-y-0 left-0 bg-brand" style="width:35%;"></span>
</span>
<span class="block h-[3px] w-[18px] bg-bg-rule-strong"></span>
<span class="block h-[3px] w-[18px] bg-bg-rule-strong"></span>
</div>
<div class="text-[12px] text-ink-3">
<span class="text-ink font-semibold">1</span> von 3 Top-Meldungen
</div>
</div>
</article>
{{-- SIDEBAR: Was sonst zählt --}}
<aside>
<div class="eyebrow muted mb-3.5">Was sonst zählt</div>
@if ($sideReleases->isNotEmpty())
@foreach ($sideReleases as $index => $release)
<x-web.focus-side-item :release="$release" :index="$index + 1" :first="$loop->first" />
@endforeach
@else
@foreach ([
['n' => '01', 'cat' => 'Energie & Umwelt', 'time' => '10:42', 'title' => 'Energiewende beschleunigt sich: Neue Rekorde bei Erneuerbaren', 'company' => 'GreenPower Deutschland', 'gradient' => 'linear-gradient(135deg,#3A5A3A,#7BA876)'],
['n' => '02', 'cat' => 'Finanzen', 'time' => '08:15', 'title' => 'FinTech-Startup PaymentFlow sichert sich 45 Mio. Euro', 'company' => 'PaymentFlow GmbH', 'gradient' => 'linear-gradient(135deg,#2A3548,#6B7C95)'],
['n' => '03', 'cat' => 'Industrie', 'time' => '07:00', 'title' => 'Deutsche Unternehmen investieren 15 Mrd. in Automatisierung', 'company' => 'TechVision Analytics', 'gradient' => 'linear-gradient(135deg,#3A2A22,#9B6B52)'],
['n' => '04', 'cat' => 'Mobilität', 'time' => 'Gestern', 'title' => 'VW eröffnet Batteriewerk in Salzgitter — 4.000 Arbeitsplätze', 'company' => 'Volkswagen AG', 'gradient' => 'linear-gradient(135deg,#1F2A3A,#5B7A99)'],
] as $idx => $mock)
<x-web.focus-side-item :mock="$mock" :first="$idx === 0" />
@endforeach
@endif
<x-web.focus-ad :ad="$advertisement" />
</aside>
</div>
</section>

View file

@ -0,0 +1,72 @@
@props([
'release' => null,
'mock' => null,
'index' => 1,
'first' => false,
])
@php
if ($release) {
$category = $release?->category?->translations?->firstWhere('locale', 'de')
?? $release?->category?->translations?->first();
$categoryName = $category?->name ?? 'Wirtschaft';
$time = $release?->published_at?->isToday()
? $release->published_at->format('H:i')
: ($release?->published_at?->isYesterday() ? 'Gestern' : $release?->published_at?->isoFormat('D. MMM'));
$title = $release->title;
$company = $release?->company?->name ?? 'Unternehmen';
$href = route('release.detail', ['slug' => $release->slug]);
$image = $release?->images?->first();
$imageUrl = $image?->variantUrl('card') ?? $image?->url();
$gradient = 'linear-gradient(135deg,#3A4A5A,#6B7C95)';
$number = str_pad((string) $index, 2, '0', STR_PAD_LEFT);
} else {
$categoryName = $mock['cat'];
$time = $mock['time'];
$title = $mock['title'];
$company = $mock['company'];
$href = '#';
$imageUrl = null;
$gradient = $mock['gradient'] ?? 'linear-gradient(135deg,#3A4A5A,#6B7C95)';
$number = $mock['n'];
}
@endphp
<article class="px-3.5 py-4 border-b border-bg-rule cursor-pointer transition-colors hover:bg-bg-elev group {{ $first ? 'border-t border-bg-rule-strong' : '' }}">
<a href="{{ $href }}" class="block cursor-pointer">
<div class="grid items-baseline gap-3 mb-2.5" style="grid-template-columns: 32px 1fr auto;">
<div class="font-serif text-[18px] text-brand font-medium leading-none tabular-nums">{{ $number }}</div>
<span class="bp-cat">{{ $categoryName }}</span>
<span class="font-mono text-[11px] text-ink-3">{{ $time }}</span>
</div>
<div class="grid items-start gap-3" style="grid-template-columns: 32px 64px 1fr;">
<div></div>
<div class="w-16 h-16 flex-shrink-0 relative overflow-hidden" style="background: {{ $gradient }};">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else
<svg width="64" height="64" viewBox="0 0 64 64" class="absolute inset-0" aria-hidden="true">
<circle cx="48" cy="20" r="18" fill="rgba(255,255,255,0.08)" />
<rect x="6" y="40" width="36" height="2" fill="rgba(255,255,255,0.18)" />
<rect x="6" y="46" width="24" height="2" fill="rgba(255,255,255,0.12)" />
</svg>
@endif
</div>
<div>
<h3 class="font-serif text-[15.5px] leading-[1.3] m-0 font-medium tracking-[-0.1px] text-ink group-hover:text-brand transition-colors">
{{ $title }}
</h3>
<div class="text-[11.5px] text-ink-3 mt-1.5 flex items-center gap-1.5">
<svg width="10" height="10" viewBox="0 0 11 11" class="text-ok flex-shrink-0" aria-hidden="true">
<circle cx="5.5" cy="5.5" r="5" fill="currentColor" />
<path d="M3 5.5l1.8 1.8L8 4" stroke="white" stroke-width="1.4" fill="none" stroke-linecap="round" />
</svg>
<span>{{ $company }}</span>
</div>
</div>
</div>
</a>
</article>

View file

@ -0,0 +1,30 @@
@props(['title', 'subtitle', 'theme' => 'businessportal24'])
<section
class="hero-gradient relative overflow-hidden text-white py-6 animate-fade-in border-b border-zinc-100 dark:border-zinc-900">
<div class="container mx-auto px-4">
<div class="mx-auto">
<h1
class="hero-title text-2xl md:text-3xl lg:text-4xl text-white font-bold mb-4 animate-fade-in-up text-center">
{!! $title !!}
</h1>
<p class="hero-subtitle text-base md:text-lg text-white animate-fade-in-up animation-delay-200 text-center">
{{ $subtitle }}
</p>
</div>
</div>
@if ($theme === 'businessportal24')
<!-- Decorative Pattern -->
<div class="absolute top-0 right-0 opacity-10">
<svg width="404" height="384" fill="none" viewBox="0 0 404 384">
<defs>
<pattern id="pattern-hero" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="4" height="4" fill="currentColor" />
</pattern>
</defs>
<rect width="404" height="384" fill="url(#pattern-hero)" />
</svg>
</div>
@endif
</section>

View file

@ -0,0 +1,88 @@
@props(['highlight'])
@php
// Map industry to Heroicon
$industryIcons = [
'IT & Digitalisierung' => 'cpu-chip',
'KI & Innovation' => 'cpu-chip',
'Industrie & Technik' => 'cog-6-tooth',
'Finanzen & Versicherungen' => 'currency-dollar',
'Handel & E-Commerce' => 'shopping-cart',
'Bauen & Immobilien' => 'building-office',
'Mobilität & Logistik' => 'truck',
'Energie & Umwelt' => 'bolt',
'Medizin & Gesundheit' => 'heart',
'Personal & HR' => 'user-group',
'Marketing, PR & Medien' => 'megaphone',
'Recht & Steuern' => 'scale',
'Politik, Verbände & NGOs' => 'flag',
'Wissenschaft & Forschung' => 'beaker',
'Lifestyle' => 'sparkles',
'Tourismus & Kultur' => 'globe-alt',
];
$iconName = $industryIcons[$highlight['industry']] ?? 'building-office';
$iconPath = "/heroicons/optimized/24/outline/{$iconName}.svg";
@endphp
<a href="#" class="highlight-card-link">
<article class="highlight-card">
<div class="grid md:grid-cols-2">
<!-- Image -->
<div class="highlight-card-image">
<img src="{{ $highlight['image'] }}" alt="{{ $highlight['title'] }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
<div class="absolute top-4 left-4">
<span class="highlight-badge badge badge-{{ $highlight['badgeType'] }}">
{{ $highlight['badge'] }}
</span>
</div>
</div>
<!-- Content -->
<div class="highlight-card-content">
<div>
{{-- Meta Info --}}
<div class="highlight-meta">
<div class="flex items-center gap-2">
<div class="industry-icon-badge">
<img src="{{ $iconPath }}" alt="{{ $highlight['industry'] }}" class="h-4 w-4" />
</div>
<span
class="font-medium text-zinc-700 dark:text-zinc-300">{{ $highlight['industry'] }}</span>
</div>
<time datetime="{{ $highlight['date'] }}"
class="text-zinc-600 dark:text-zinc-400">{{ $highlight['date'] }}</time>
</div>
{{-- Title --}}
<h3 class="highlight-title">
{{ $highlight['title'] }}
</h3>
{{-- Teaser --}}
<p class="highlight-text">
{{ $highlight['text'] }}
</p>
</div>
{{-- Footer --}}
<div class="highlight-footer">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
<span
class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $highlight['author'] }}</span>
</div>
<svg class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</div>
</div>
</article>
</a>

View file

@ -0,0 +1,129 @@
@props([
'highlights' => [],
'theme' => 'businessportal24',
])
@php
$sectionBgClass = match ($theme) {
'presseecho' => 'bg-gradient-to-br from-zinc-50 to-zinc-100 dark:from-zinc-800 dark:to-zinc-900',
'businessportal24' => 'main-content-section',
default => 'bg-white dark:bg-zinc-950',
};
@endphp
<section class="relative overflow-hidden {{ $sectionBgClass }} py-12 md:py-16 animate-fade-in">
<div class="container mx-auto px-4">
<!-- Section Header -->
<div class="mb-8 flex items-end justify-between">
<div>
<h2 class="text-3xl md:text-4xl font-bold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-3">
<span class="gradient-indicator"></span>
Highlights der Woche
</h2>
<p class="text-zinc-600 dark:text-zinc-400 ml-5">Exklusive Einblicke und Top-Analysen</p>
</div>
<!-- Navigation Buttons -->
<div class="hidden md:flex items-center gap-2">
<button id="highlights-prev" class="slider-nav-btn" aria-label="Vorheriges Highlight">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7">
</path>
</svg>
</button>
<button id="highlights-next" class="slider-nav-btn" aria-label="Nächstes Highlight">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
<!-- Slider Container -->
<div class="slider-wrapper">
<div id="highlights-slider" class="highlights-slider">
@foreach ($highlights as $highlight)
<x-web.highlight-card :highlight="$highlight" />
@endforeach
</div>
<!-- Pagination Dots -->
<div class="slider-dots">
@foreach ($highlights as $index => $highlight)
<button data-slide="{{ $index }}" class="slider-dot {{ $index === 0 ? 'active' : '' }}"
aria-label="Zu Slide {{ $index + 1 }}"></button>
@endforeach
</div>
</div>
</div>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
const slider = document.getElementById('highlights-slider');
const prevBtn = document.getElementById('highlights-prev');
const nextBtn = document.getElementById('highlights-next');
const dots = document.querySelectorAll('.slider-dot');
if (!slider || !prevBtn || !nextBtn) return;
let currentSlide = 0;
const totalSlides = dots.length;
function updateSlider() {
const slideWidth = slider.children[0].offsetWidth;
slider.scrollTo({
left: currentSlide * (slideWidth + 24), // 24px = gap-6
behavior: 'smooth'
});
updateDots();
updateButtons();
}
function updateDots() {
dots.forEach((dot, index) => {
dot.classList.toggle('active', index === currentSlide);
});
}
function updateButtons() {
prevBtn.disabled = currentSlide === 0;
nextBtn.disabled = currentSlide === totalSlides - 1;
}
prevBtn.addEventListener('click', () => {
if (currentSlide > 0) {
currentSlide--;
updateSlider();
}
});
nextBtn.addEventListener('click', () => {
if (currentSlide < totalSlides - 1) {
currentSlide++;
updateSlider();
}
});
dots.forEach((dot, index) => {
dot.addEventListener('click', () => {
currentSlide = index;
updateSlider();
});
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && currentSlide > 0) {
currentSlide--;
updateSlider();
} else if (e.key === 'ArrowRight' && currentSlide < totalSlides - 1) {
currentSlide++;
updateSlider();
}
});
// Initialize
updateButtons();
});
</script>

View file

@ -0,0 +1,57 @@
@props([
'stats' => [],
])
@php
$publishedCount = (int) ($stats['publishedCount'] ?? 0);
$publishedToday = (int) ($stats['publishedToday'] ?? 0);
$archiveSince = $stats['archiveSince'] ?? null;
@endphp
<section class="relative overflow-hidden bg-[#f7f4ef]">
<div class="absolute inset-x-0 top-0 h-px bg-black/10 dark:bg-white/10"></div>
<div class="mx-auto grid max-w-7xl gap-10 px-4 py-12 sm:px-6 md:py-16 lg:grid-cols-[minmax(0,1fr)_24rem] lg:px-8">
<div class="max-w-3xl">
<p class="text-xs font-semibold uppercase tracking-[0.32em] text-[#b6332a]">Deutschland aktiv</p>
<h1 class="mt-5 text-4xl font-semibold tracking-tight text-zinc-950 dark:text-white sm:text-5xl lg:text-6xl">
Aktuelle Pressemitteilungen aus der deutschen Wirtschaft
</h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-zinc-700 dark:text-zinc-300">
Neue Unternehmensmeldungen, Personalien, Produktankündigungen und Wirtschaftsnachrichten aus Mittelstand, Industrie und Dienstleistung.
</p>
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="#aktuell" class="inline-flex items-center justify-center rounded-full bg-[#cf3628] px-5 py-3 text-sm font-semibold text-white transition hover:bg-[#a92c25]">
Aktuelle Meldungen lesen
</a>
<a href="{{ route('veroeffentlichen') }}" class="inline-flex items-center justify-center rounded-full border border-black/15 px-5 py-3 text-sm font-semibold text-zinc-800 transition hover:border-[#cf3628]/50 hover:text-[#cf3628] dark:border-white/15 dark:text-zinc-100">
Pressemitteilung veröffentlichen
</a>
</div>
</div>
<div class="rounded-2xl border border-black/10 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-zinc-900">
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-zinc-500 dark:text-zinc-400">Archiv & Einordnung</p>
<dl class="mt-6 grid gap-5">
<div class="border-b border-black/10 pb-5 dark:border-white/10">
<dt class="text-sm text-zinc-500 dark:text-zinc-400">Veröffentlichte Meldungen</dt>
<dd class="mt-1 text-3xl font-semibold text-zinc-950 dark:text-white">{{ number_format($publishedCount, 0, ',', '.') }}</dd>
</div>
<div class="border-b border-black/10 pb-5 dark:border-white/10">
<dt class="text-sm text-zinc-500 dark:text-zinc-400">Heute neu</dt>
<dd class="mt-1 text-3xl font-semibold text-zinc-950 dark:text-white">{{ number_format($publishedToday, 0, ',', '.') }}</dd>
</div>
@if ($archiveSince)
<div>
<dt class="text-sm text-zinc-500 dark:text-zinc-400">Archiv seit</dt>
<dd class="mt-1 text-3xl font-semibold text-zinc-950 dark:text-white">{{ $archiveSince }}</dd>
</div>
@endif
</dl>
<p class="mt-6 text-sm leading-6 text-zinc-600 dark:text-zinc-300">
Die Zahlen stammen aus dem aktuellen Datenbestand. Fehlende Regionaldaten werden nicht ergänzt oder geraten.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,62 @@
@props([
'industries' => null,
])
@php
$industries = $industries ?? [
['name' => 'Technologie', 'count' => 142, 'delta' => 12, 'href' => route('kategorien')],
['name' => 'Finanzen', 'count' => 98, 'delta' => 5, 'href' => route('kategorien')],
['name' => 'Industrie', 'count' => 87, 'delta' => -2, 'href' => route('kategorien')],
['name' => 'Energie', 'count' => 64, 'delta' => 18, 'href' => route('kategorien')],
['name' => 'Gesundheit', 'count' => 51, 'delta' => 3, 'href' => route('kategorien')],
['name' => 'Mobilität', 'count' => 44, 'delta' => 9, 'href' => route('kategorien')],
['name' => 'Handel', 'count' => 38, 'delta' => -1, 'href' => route('kategorien')],
['name' => 'Immobilien', 'count' => 32, 'delta' => 4, 'href' => route('kategorien')],
];
$maxCount = max(1, collect($industries)->max('count'));
$industries = array_slice($industries, 0, 8);
@endphp
<section class="max-w-layout mx-auto px-8 mt-16">
<header class="flex items-baseline justify-between mb-4 flex-wrap gap-3">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] text-ink">Branchen-Index</h2>
<span class="eyebrow muted">Letzte 7 Tage</span>
</header>
<hr class="rule-strong">
<div class="grid border-b border-bg-rule grid-cols-2 md:grid-cols-4">
@foreach ($industries as $idx => $industry)
@php
$delta = (int) ($industry['delta'] ?? 0);
$direction = $delta > 0 ? 'gain' : ($delta < 0 ? 'loss' : 'flat');
$percent = max(20, (int) round(($industry['count'] ?? 0) / $maxCount * 100));
$isTopRow = $idx < 4;
$isLastInRow = ($idx + 1) % 4 === 0;
$barClass = $delta < 0 ? 'bg-brand' : 'bg-gain';
@endphp
<a href="{{ $industry['href'] ?? route('kategorien') }}"
class="block px-5 py-[18px] {{ $isLastInRow ? '' : 'md:border-r border-bg-rule' }} {{ $isTopRow ? '' : 'border-t border-bg-rule' }} cursor-pointer hover:bg-bg-elev transition-colors group">
<div class="flex justify-between items-baseline mb-2 gap-2">
<span class="text-[14px] font-semibold text-ink group-hover:text-brand transition-colors">{{ $industry['name'] }}</span>
<span class="font-mono text-[12px] font-semibold
@if ($direction === 'gain') text-gain
@elseif ($direction === 'loss') text-loss
@else text-ink-3 @endif">
@if ($delta > 0) +{{ $delta }}
@elseif ($delta < 0) {{ $delta }}
@else ±0
@endif
</span>
</div>
<div class="flex items-baseline gap-2 mb-2">
<span class="font-serif text-[22px] font-semibold tracking-[-0.3px] text-ink">{{ $industry['count'] }}</span>
<span class="text-[11px] text-ink-3">Meldungen</span>
</div>
<div class="h-[3px] bg-bg-rule">
<div class="h-full {{ $barClass }}" style="width: {{ $percent }}%;"></div>
</div>
</a>
@endforeach
</div>
</section>

View file

@ -0,0 +1,119 @@
@props([
'industry' => null,
'stats' => null,
'releases' => [],
'study' => null,
])
@php
$industry ??= 'Energie & Klima';
$stats ??= [
['label' => 'Meldungen heute', 'value' => '47', 'sub' => '+18 ggü. Wochenschnitt'],
['label' => 'Investitions­volumen', 'value' => '€8,4 Mrd.', 'sub' => 'Q4 2025 angekündigt'],
['label' => 'Aktive Unternehmen', 'value' => '23', 'sub' => 'in dieser Branche heute'],
];
$releases = collect($releases);
if ($releases->isEmpty()) {
$releases = collect([
['time' => '14:12', 'date' => '12. Mai', 'category' => 'Energie', 'title' => 'RWE startet 1,8 GW Offshore-Windpark vor Helgoland — größtes Projekt in der Nordsee 2025', 'company' => 'RWE AG', 'city' => 'Essen', 'minutes' => 4, 'href' => '#'],
['time' => '12:28', 'date' => '12. Mai', 'category' => 'Energie', 'title' => 'EnBW unterzeichnet Wasserstoff-Lieferabkommen mit Air Liquide', 'company' => 'EnBW Energie', 'city' => 'Karlsruhe', 'minutes' => 3, 'href' => '#'],
['time' => '10:55', 'date' => '12. Mai', 'category' => 'Klima', 'title' => 'Stadtwerke München erreichen 100 % Ökostrom-Ziel für Privatkunden', 'company' => 'SWM', 'city' => 'München', 'minutes' => 2, 'href' => '#'],
]);
}
$study ??= [
'kicker' => 'STUDIE · ENERGIE',
'title' => 'Energiewende-Monitor 2025: Investitionen, Engpässe, Potenziale im DACH-Raum',
'source' => 'Fraunhofer-Institut für Solare Energiesysteme',
'meta' => 'PDF · 84 Seiten · Veröffentlicht 11. Nov 2025',
'href' => '#',
];
@endphp
<section class="max-w-layout mx-auto px-8 mt-16">
<header class="flex items-baseline justify-between mb-4 flex-wrap gap-3">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] text-ink">
Heute im Fokus <span class="text-brand">· {{ $industry }}</span>
</h2>
<div class="eyebrow muted">Aktualisiert {{ now()->format('H:i') }} Uhr · {{ $stats[0]['value'] ?? '47' }} Meldungen heute</div>
</header>
<hr class="rule-strong">
<div class="grid grid-cols-1 md:grid-cols-3 border-b border-bg-rule">
@foreach ($stats as $idx => $stat)
<div class="px-6 py-5 {{ $idx < count($stats) - 1 ? 'md:border-r border-bg-rule' : '' }}">
<div class="eyebrow muted mb-2">{{ $stat['label'] }}</div>
<div class="font-serif text-[32px] font-semibold leading-none tracking-[-0.5px] text-ink">{{ $stat['value'] }}</div>
<div class="text-[11.5px] text-ink-3 mt-1.5">{{ $stat['sub'] }}</div>
</div>
@endforeach
</div>
<div class="grid gap-9 mt-6 grid-cols-1 lg:grid-cols-[1.7fr_1fr]">
<div>
@foreach ($releases as $release)
<a href="{{ $release['href'] }}" @class([
'grid items-start border-b border-bg-rule py-4',
'cursor-pointer transition-colors group hover:bg-bg-elev',
'pl-1.5 pr-2 sm:pl-2',
'max-md:grid-cols-[auto_minmax(0,1fr)] max-md:gap-x-3',
'md:grid-cols-[3.75rem_6.25rem_minmax(0,1fr)] md:gap-x-4',
])>
<div class="flex min-w-0 shrink-0 flex-col gap-0.5 tabular-nums pt-0.5">
<span class="font-mono text-[11px] leading-none text-ink-3 sm:text-[12px]">{{ $release['time'] }}</span>
@if (($release['date'] ?? '') !== '')
<span class="text-[9px] leading-tight text-ink-4 sm:text-[10px]">{{ $release['date'] }}</span>
@endif
<span class="bp-cat self-start whitespace-nowrap leading-snug max-md:mt-1 max-md:text-[9px] max-md:tracking-[0.1em] md:hidden">{{ $release['category'] }}</span>
</div>
<span class="bp-cat hidden self-start whitespace-nowrap leading-snug md:inline">{{ $release['category'] }}</span>
<div class="min-w-0">
<div class="font-serif text-[16px] font-medium leading-[1.3] tracking-[-0.1px] text-ink transition-colors group-hover:text-brand">
{{ $release['title'] }}
</div>
<div class="text-[11.5px] text-ink-3 mt-1 flex items-center gap-1.5 flex-wrap">
<svg width="10" height="10" viewBox="0 0 11 11" class="text-ok" aria-hidden="true">
<circle cx="5.5" cy="5.5" r="5" fill="currentColor" />
<path d="M3 5.5l1.8 1.8L8 4" stroke="white" stroke-width="1.4" fill="none" stroke-linecap="round" />
</svg>
<span>{{ $release['company'] }}</span>
@if ($release['city'] ?? null)
<span aria-hidden="true">·</span>
<span>{{ $release['city'] }}</span>
@endif
@if ($release['minutes'] ?? null)
<span aria-hidden="true">·</span>
<span>{{ $release['minutes'] }} Min.</span>
@endif
</div>
</div>
</a>
@endforeach
</div>
<aside>
<div class="bg-bg-card-warm p-6 border-t-[3px] border-brand">
<div class="eyebrow mb-2.5">{{ $study['kicker'] }}</div>
<h3 class="font-serif text-[20px] font-semibold m-0 mb-3 tracking-[-0.2px] leading-[1.25] text-ink">
{{ $study['title'] }}
</h3>
<div class="text-[12px] text-ink-3 mb-3.5">
{{ $study['source'] }}<br>
<span class="font-mono text-[11px]">{{ $study['meta'] }}</span>
</div>
<a href="{{ $study['href'] }}"
class="w-full inline-flex items-center justify-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold bg-transparent text-ink border border-ink rounded-[2px] cursor-pointer hover:bg-ink hover:text-bg transition-colors">
Studie herunterladen
</a>
<div class="mt-2.5 text-[10.5px] leading-[1.45] flex items-center gap-1.5 text-ink-3">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="flex-shrink-0 text-ink-4" aria-hidden="true">
<rect x="1.5" y="3" width="9" height="6.5" stroke="currentColor" stroke-width="1" />
<path d="M1.5 3l4.5 3.5L10.5 3" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
<span>Kostenlos · Anmeldung per E-Mail erforderlich</span>
</div>
</div>
</aside>
</div>
</section>

View file

@ -0,0 +1,112 @@
@props([
'items' => [],
'marketIndex' => null,
'asideSlides' => null,
])
@php
$items = collect($items)->all();
if (empty($items)) {
$items = [
['time' => '14:32', 'text' => 'Siemens Energy hebt Jahresprognose an'],
['time' => '14:18', 'text' => 'BASF: Restrukturierung in Ludwigshafen abgeschlossen'],
['time' => '13:55', 'text' => 'Deutsche Bahn meldet Rekord bei Fernverkehr'],
];
}
$marketIndex ??= [
'label' => 'DAX',
'value' => '18.247',
'delta' => '+0,8%',
'positive' => true,
];
$asideSlides = collect($asideSlides ?? [
$marketIndex,
[
'label' => 'EUR/USD',
'value' => '1,08',
'delta' => '+0,2%',
'positive' => true,
],
[
'label' => 'Brent Öl',
'value' => '78,40 $',
'delta' => '-0,9%',
'positive' => false,
],
])->values()->all();
@endphp
<div class="bg-topbar-grad text-ink-on-dark" aria-label="Ad-Hoc-Ticker">
<div class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-3 sm:gap-5 text-[12.5px]">
<div class="flex items-center gap-3 sm:gap-4 overflow-hidden min-w-0 flex-1">
<span class="flex items-center gap-1.5 text-accent-warm font-bold text-[10.5px] sm:text-[11px] tracking-[0.15em] flex-shrink-0">
<span class="w-[7px] h-[7px] rounded-full bg-accent-warm pulse-dot"></span>
AD-HOC
</span>
<span class="sr-only">
Ad-hoc-Meldungen:
@foreach ($items as $item)
{{ $item['time'] }} {{ $item['text'] }}.
@endforeach
</span>
<div class="overflow-hidden min-w-0 flex-1" aria-hidden="true">
<div class="ticker-marquee-track text-[12px] sm:text-[12.5px] whitespace-nowrap">
@foreach (array_merge($items, $items) as $item)
<span class="inline-flex shrink-0 gap-2 items-baseline">
<span class="font-mono text-[11px] text-ink-on-dark-muted">{{ $item['time'] }}</span>
<span class="text-ink-on-dark">{{ $item['text'] }}</span>
</span>
@endforeach
</div>
</div>
</div>
<span class="hidden sm:inline-block w-px h-[18px] bg-ink-on-dark-rule flex-shrink-0"></span>
{{-- Kein position:absolute: sonst null Breite im Fluss, bis Layout kollabiert --}}
<div class="shrink-0 flex justify-end items-center min-w-0 max-w-[min(92vw,280px)] sm:max-w-[min(42vw,280px)]"
x-data="{
slides: @js($asideSlides),
i: 0,
init() {
if (this.slides.length < 2) {
return;
}
window.setInterval(() => {
this.i = (this.i + 1) % this.slides.length;
}, 5200);
},
}"
aria-live="polite">
<div class="grid min-h-[22px] justify-items-end overflow-hidden [grid-template-columns:minmax(0,max-content)]">
<template x-for="(slide, idx) in slides" :key="idx">
<div x-show="i === idx"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 -translate-y-full"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-280"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-full"
class="col-start-1 row-start-1 flex items-center gap-2 whitespace-nowrap">
<span class="text-ink-on-dark-muted font-semibold text-[10.5px] tracking-wider" x-text="slide.label"></span>
<span class="text-ink-on-dark font-mono text-[12.5px]" x-text="slide.value"></span>
<span class="font-semibold text-[11.5px] font-mono"
:class="(slide.positive ?? true) ? 'text-gain' : 'text-loss'"
x-text="slide.delta"></span>
<svg width="26" height="10" viewBox="0 0 26 10"
class="hidden sm:inline-block shrink-0"
:class="(slide.positive ?? true) ? 'text-gain' : 'text-loss'"
aria-hidden="true">
<path d="M0,8 L4,7 L8,5 L12,6 L16,4 L20,3 L24,2"
fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
</div>
</template>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
@props([
'theme' => 'presseecho',
'items' => [],
])
@php
$defaultItems = [
'presseecho' => [
['label' => 'Themendossiers', 'url' => '/themendossiers'],
['label' => 'Fachbereiche', 'url' => '/fachbereiche'],
['label' => 'Experten', 'url' => '/experten'],
],
'businessportal24' => [
['label' => 'Unternehmen', 'url' => '/unternehmen'],
['label' => 'Branchen', 'url' => '/branchen'],
['label' => 'Regionen', 'url' => '/regionen'],
],
];
$navItems = !empty($items) ? $items : ($defaultItems[$theme] ?? $defaultItems['presseecho']);
@endphp
<nav class="sticky top-0 z-40 bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 shadow-sm backdrop-blur-sm transition-colors duration-200">
<div class="container mx-auto px-4">
<div class="flex items-center justify-center gap-8 h-14">
@foreach($navItems as $index => $item)
@if($index > 0)
<span class="text-zinc-300 dark:text-zinc-700">|</span>
@endif
<a href="{{ $item['url'] }}"
class="main-nav-link text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-[var(--color-primary)] dark:hover:text-[var(--color-secondary)] transition-colors relative group py-4">
{{ $item['label'] }}
<span class="absolute bottom-0 left-0 w-0 h-0.5 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] group-hover:w-full transition-all duration-300"></span>
</a>
@endforeach
</div>
</div>
</nav>

View file

@ -0,0 +1,48 @@
@props([
'releases' => [],
])
@php
$releases = collect($releases)->take(4)->values();
$fallback = collect([
['title' => 'FinTech-Startup PaymentFlow sichert sich 45 Mio. Euro', 'hits' => 12428],
['title' => 'Energiewende: Neue Rekorde bei Erneuerbaren', 'hits' => 9831],
['title' => 'Immobilienmarkt 2025: Trendwende bei Kaufpreisen', 'hits' => 7104],
['title' => 'Telemedizin-Boom: 3 Mio. Online-Sprechstunden', 'hits' => 5298],
]);
$items = $releases->isNotEmpty()
? $releases->map(fn ($release) => [
'title' => \Illuminate\Support\Str::limit($release->title, 70),
'hits' => (int) ($release->hits ?? 0),
'href' => route('release.detail', ['slug' => $release->slug]),
])
: $fallback->map(fn ($mock) => array_merge($mock, ['href' => '#']));
$maxHits = max(1, $items->max('hits'));
@endphp
<section>
<header class="flex items-baseline justify-between mb-4 min-h-[34px]">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] leading-[1.2] text-ink">Meistgelesen</h2>
</header>
<hr class="rule-strong mb-1">
@foreach ($items as $index => $item)
@php
$percent = max(15, (int) round($item['hits'] / $maxHits * 100));
$isLast = $loop->last;
@endphp
<a href="{{ $item['href'] }}" class="block py-3.5 {{ $isLast ? '' : 'border-b border-bg-rule' }} cursor-pointer hover:bg-bg-elev transition-colors group">
<div class="grid gap-2.5 items-baseline mb-1.5" style="grid-template-columns: 32px 1fr auto;">
<div class="font-serif text-[18px] text-brand font-semibold leading-none tabular-nums">{{ $index + 1 }}</div>
<div class="font-serif text-[14px] leading-[1.3] font-medium text-ink group-hover:text-brand transition-colors">{{ $item['title'] }}</div>
<span class="font-mono text-[11px] text-ink-3">{{ number_format((int) $item['hits'], 0, ',', '.') }}</span>
</div>
<div class="ml-[42px] h-[3px] bg-bg-rule">
<div class="h-full bg-brand" style="width: {{ $percent }}%;"></div>
</div>
</a>
@endforeach
</section>

View file

@ -0,0 +1,76 @@
@props([
'topics' => null,
'subscribers' => 84000,
])
@php
$themeKey = config('app.theme');
$topics ??= config('domains.domains.'.$themeKey.'.brand.newsletter_topics', [
['name' => 'Tageszusammenfassung', 'meta' => 'MoFr · 07:00 Uhr', 'active' => true],
['name' => 'Wochenrückblick', 'meta' => 'Sonntags · 18:00 Uhr', 'active' => false],
['name' => 'Energie & Klima', 'meta' => 'ab Content-Score 80 · max. 2× pro Woche', 'active' => true],
['name' => 'IPO & M&A', 'meta' => 'ausgewählt von der Redaktion · ca. 35 Mails/Monat', 'active' => false],
]);
@endphp
<section id="newsletter" class="max-w-layout mx-auto px-8 mt-16">
<div class="relative overflow-hidden bg-topbar-grad text-ink-on-dark p-12 px-14">
<svg width="280" height="280" viewBox="0 0 280 280" class="absolute -right-10 -top-10 opacity-[0.06]" aria-hidden="true">
<circle cx="140" cy="140" r="120" stroke="white" stroke-width="1" fill="none" />
<circle cx="140" cy="140" r="80" stroke="white" stroke-width="1" fill="none" />
<circle cx="140" cy="140" r="40" stroke="white" stroke-width="1" fill="none" />
</svg>
<div class="relative grid gap-12 grid-cols-1 lg:grid-cols-[1.1fr_1fr]">
<div>
<div class="eyebrow on-dark mb-3">Bleiben Sie informiert</div>
<h2 class="font-serif text-[30px] font-semibold m-0 leading-[1.18] tracking-[-0.4px] mb-3.5 max-w-[460px] text-white">
Pressemeldungen, kuratiert in Ihren Posteingang
</h2>
<p class="text-[14px] leading-[1.55] text-white/70 max-w-[440px] m-0 mb-6">
Die wichtigsten Meldungen aus DACH täglich, wöchentlich oder branchenspezifisch. Keine Werbung, jederzeit kündbar.
</p>
<form action="#newsletter-subscribe" method="post" class="flex mb-3.5 max-w-[460px]">
@csrf
<label class="sr-only" for="newsletter-email">E-Mail-Adresse</label>
<input id="newsletter-email" type="email" name="email" required
placeholder="Ihre E-Mail-Adresse"
class="flex-1 px-4 py-3.5 text-[14px] bg-white/[0.08] border border-white/[0.18] text-white outline-none rounded-none placeholder-white/40 focus:border-brand focus:ring-0">
<button type="submit"
class="px-[22px] py-3.5 text-[13px] font-semibold bg-brand text-white whitespace-nowrap rounded-none cursor-pointer hover:bg-brand-deep transition-colors">
Kostenlos abonnieren
</button>
</form>
<div class="text-[11px] text-white/50">
Über {{ number_format($subscribers, 0, ',', '.') }} Abonnenten · Datenschutz nach DSGVO
</div>
</div>
<div class="flex flex-col gap-2.5 self-stretch">
@foreach ($topics as $topic)
<label class="flex items-start gap-3.5 px-4 py-3.5 cursor-pointer transition-colors
{{ ($topic['active'] ?? false) ? 'bg-white/[0.06] border border-brand/40 hover:bg-white/[0.1]' : 'border border-white/10 hover:border-white/30 hover:bg-white/[0.04]' }}">
@if ($topic['active'] ?? false)
<span class="w-[18px] h-[18px] flex-shrink-0 mt-0.5 bg-brand border border-brand flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 6l3 3 5-6" stroke="white" stroke-width="2" />
</svg>
</span>
@else
<span class="w-[18px] h-[18px] flex-shrink-0 mt-0.5 border border-white/30"></span>
@endif
<div class="flex-1">
<div class="text-[13.5px] font-semibold text-white">{{ $topic['name'] }}</div>
<div class="text-[11.5px] text-white/55 mt-0.5">{{ $topic['meta'] }}</div>
</div>
<input type="checkbox" name="topics[]" value="{{ \Illuminate\Support\Str::slug($topic['name']) }}"
{{ ($topic['active'] ?? false) ? 'checked' : '' }} class="sr-only">
</label>
@endforeach
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,50 @@
@props([
'title',
'subtitle' => null,
'meta' => null,
'image' => null,
'compact' => false,
])
<header class="page-header {{ $compact ? 'page-header-compact' : '' }}">
<div class="container mx-auto px-4">
@if($image)
<!-- Header mit Bild -->
<div class="grid md:grid-cols-2 gap-8 items-center">
<div>
@if($meta)
<div class="mb-4">
{{ $meta }}
</div>
@endif
<h1 class="page-header-title">{{ $title }}</h1>
@if($subtitle)
<p class="page-header-subtitle">{{ $subtitle }}</p>
@endif
</div>
<div class="relative h-64 md:h-80 rounded-xl overflow-hidden">
<img src="{{ $image }}" alt="{{ $title }}" class="w-full h-full object-cover">
</div>
</div>
@else
<!-- Header ohne Bild -->
<div class="max-w-4xl">
@if($meta)
<div class="mb-4">
{{ $meta }}
</div>
@endif
<h1 class="page-header-title">{{ $title }}</h1>
@if($subtitle)
<p class="page-header-subtitle">{{ $subtitle }}</p>
@endif
</div>
@endif
</div>
</header>

View file

@ -0,0 +1,82 @@
@props([
'currentPage' => 1,
'totalPages' => 10,
'showFirstLast' => true,
'maxVisible' => 5,
])
@php
$hasPrevious = $currentPage > 1;
$hasNext = $currentPage < $totalPages;
// Berechne sichtbare Seitenzahlen
$visiblePages = [];
if ($totalPages <= $maxVisible) {
// Zeige alle Seiten wenn weniger als maxVisible
$visiblePages = range(1, $totalPages);
} else {
// Berechne dynamischen Bereich
$start = max(1, $currentPage - floor($maxVisible / 2));
$end = min($totalPages, $start + $maxVisible - 1);
// Korrigiere Start wenn am Ende
if ($end - $start < $maxVisible - 1) {
$start = max(1, $end - $maxVisible + 1);
}
$visiblePages = range($start, $end);
}
$showStartEllipsis = $visiblePages[0] > 1;
$showEndEllipsis = end($visiblePages) < $totalPages;
@endphp
<nav class="flex justify-center mt-12" aria-label="Seitennummerierung">
<div class="flex items-center gap-2">
<!-- Vorherige Seite -->
<button
class="pagination-btn pagination-nav {{ !$hasPrevious ? 'disabled:opacity-50 disabled:cursor-not-allowed' : '' }}"
{{ !$hasPrevious ? 'disabled' : '' }} aria-label="Vorherige Seite"
@if ($hasPrevious) onclick="window.location.href='?page={{ $currentPage - 1 }}'" @endif>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<!-- Erste Seite + Ellipsis -->
@if ($showStartEllipsis && $showFirstLast)
<button class="pagination-btn" onclick="window.location.href='?page=1'">
1
</button>
<span class="px-2 text-zinc-600 dark:text-zinc-400">...</span>
@endif
<!-- Sichtbare Seitenzahlen -->
@foreach ($visiblePages as $page)
<button class="pagination-btn {{ $page === $currentPage ? 'pagination-active' : '' }}"
{{ $page === $currentPage ? 'aria-current="page"' : '' }}
@if ($page !== $currentPage) onclick="window.location.href='?page={{ $page }}'" @endif>
{{ $page }}
</button>
@endforeach
<!-- Ellipsis + Letzte Seite -->
@if ($showEndEllipsis && $showFirstLast)
<span class="px-2 text-zinc-600 dark:text-zinc-400">...</span>
<button class="pagination-btn" onclick="window.location.href='?page={{ $totalPages }}'">
{{ $totalPages }}
</button>
@endif
<!-- Nächste Seite -->
<button
class="pagination-btn pagination-nav {{ !$hasNext ? 'disabled:opacity-50 disabled:cursor-not-allowed' : '' }}"
{{ !$hasNext ? 'disabled' : '' }} aria-label="Nächste Seite"
@if ($hasNext) onclick="window.location.href='?page={{ $currentPage + 1 }}'" @endif>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</nav>

View file

@ -5,114 +5,155 @@
'industry',
'region',
'date',
'hasImage' => false,
'hasPdf' => false,
'companyLogo' => null,
'contentType' => 'FACHMELDUNG', // ANALYSE, INTERVIEW, FACHMELDUNG
'slug',
'imageUrl' => null,
])
<article class="group bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden transition-all duration-300 hover:shadow-lg hover:border-[var(--color-primary)]/20 hover:scale-[1.02] shadow-sm">
<!-- Image Preview -->
<div class="relative h-48 bg-gradient-to-br from-gray-100 to-gray-50 dark:from-gray-800 dark:to-gray-900 overflow-hidden">
@if($imageUrl)
<img
src="{{ $imageUrl }}"
alt="{{ $title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
@php
// Map industry to Heroicon
$industryIcons = [
'IT & Digitalisierung' => 'cpu-chip',
'Industrie & Technik' => 'cog-6-tooth',
'Finanzen & Versicherungen' => 'currency-dollar',
'Handel & E-Commerce' => 'shopping-cart',
'Bauen & Immobilien' => 'building-office',
'Mobilität & Logistik' => 'truck',
'Energie & Umwelt' => 'bolt',
'Medizin & Gesundheit' => 'heart',
'Personal & HR' => 'user-group',
'Marketing, PR & Medien' => 'megaphone',
'Recht & Steuern' => 'scale',
'Politik, Verbände & NGOs' => 'flag',
'Wissenschaft & Forschung' => 'beaker',
'Lifestyle' => 'sparkles',
'Tourismus & Kultur' => 'globe-alt',
];
$iconName = $industryIcons[$industry] ?? 'building-office';
$iconPath = "/heroicons/optimized/24/outline/{$iconName}.svg";
// Content Type Badge Classes
$contentTypeClasses = match ($contentType) {
'ANALYSE' => 'bg-[var(--color-primary)] text-white',
'INTERVIEW' => 'bg-[var(--color-secondary)] text-white',
default => 'bg-white/80 dark:bg-zinc-900/90 border border-[var(--color-primary)]/30',
};
$badgeExtraClasses = $imageUrl ? 'shadow-md backdrop-blur-sm' : 'shadow-sm';
@endphp
<a href="/release/{{ $slug }}" class="group block">
<article
class="bg-gradient-to-br from-[var(--color-white)] to-[var(--color-primary)]/3 dark:from-[var(--color-zinc-800)] dark:to-[var(--color-primary)]/3 rounded-xl border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/20 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-[var(--color-primary)]/30 hover:translate-y-[-2px] shadow-sm h-full flex flex-col">
@if ($imageUrl)
{{-- Card mit Bild --}}
<div
class="relative h-46 bg-gradient-to-br from-zinc-100 to-zinc-50 dark:from-zinc-800 dark:to-zinc-900 overflow-hidden">
<img src="{{ $imageUrl }}" alt="{{ $title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy" />
{{-- Content Type Label --}}
<div class="absolute top-4 left-4">
<span
class="inline-flex bg-primary/10 items-center gap-2 text-[11px] font-semibold px-3 py-1.5 {{ $contentTypeClasses }} {{ $badgeExtraClasses }} rounded-xl shadow-sm">
<div class="industry-icon-badge">
<img src="{{ $iconPath }}" alt="{{ $industry }}" class="h-4 w-4" />
</div>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $industry }}</span>
</span>
</div>
</div>
{{-- Content --}}
<div class="p-7 flex flex-col flex-grow">
{{-- Meta Info --}}
<div class="flex justify-end left gap-2 mb-4 text-xs text-zinc-500 dark:text-zinc-400">
<time datetime="{{ $date }}"
class="text-zinc-600 dark:text-zinc-400">{{ $date }}</time>
</div>
{{-- Title --}}
<h3
class="text-xl font-bold text-zinc-900 dark:text-zinc-100 line-clamp-2 mb-4 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{ $title }}
</h3>
{{-- Teaser --}}
<p class="text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400 line-clamp-3 mb-5 flex-grow">
{{ $teaser }}
</p>
{{-- Company Footer --}}
<div
class="flex items-center justify-between pt-5 border-t border-[var(--color-primary)]/10 group-hover:border-[var(--color-primary)]/20 transition-all duration-300">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $company }}</span>
</div>
<svg class="h-5 w-5 transition-transform group-hover:translate-x-2" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</div>
@else
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5">
<svg class="h-16 w-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
@endif
{{-- Card ohne Bild - Textbasiert mit farbigem Hintergrund --}}
<div class="relative p-8 flex flex-col h-full">
<!-- Company Logo Overlay -->
@if($companyLogo)
<div class="absolute bottom-3 left-3 w-12 h-12 rounded-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm shadow-md flex items-center justify-center border border-gray-200/50 dark:border-gray-700/50">
<svg class="h-6 w-6 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
</div>
@endif
</div>
<!-- Content -->
<div class="p-5">
<!-- Company Name -->
@if(!$companyLogo)
<div class="mb-3 flex items-center gap-2">
<svg class="h-4 w-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $company }}</span>
</div>
@endif
<!-- Title -->
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 line-clamp-2 mb-2 group-hover:text-[var(--color-primary)] transition-colors">
{{ $title }}
</h3>
<!-- Teaser -->
<p class="text-[15px] leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 mb-4">
{{ $teaser }}
</p>
<!-- Meta Info -->
<div class="flex flex-wrap items-center gap-2 mb-4 text-xs text-gray-600 dark:text-gray-400">
<span class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
{{ $industry }}
</span>
<span></span>
<span class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{{ $region }}
</span>
<span></span>
<span class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{{ $date }}
</span>
</div>
<!-- Media Badges & CTA -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@if($hasImage)
<span class="inline-flex items-center gap-1 text-xs rounded-full px-3 py-1 bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Bild
{{-- Content Type Label --}}
<div class="mb-0 mt-0">
<span
class="inline-flex items-center gap-2 text-[11px] font-bold px-3 py-2 {{ $contentTypeClasses }} {{ $badgeExtraClasses }} rounded-xl">
<div class="industry-icon-badge">
<img src="{{ $iconPath }}" alt="{{ $industry }}" class="h-4 w-4" />
</div>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $industry }}</span>
</span>
@endif
@if($hasPdf)
<span class="inline-flex items-center gap-1 text-xs rounded-full px-3 py-1 bg-[var(--color-secondary)]/10 text-gray-700 border border-[var(--color-secondary)]/20">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</div>
{{-- Meta Info --}}
<div class="flex justify-end left gap-2 mb-10 text-xs text-zinc-500 dark:text-zinc-400">
<time datetime="{{ $date }}"
class="text-zinc-600 dark:text-zinc-400">{{ $date }}</time>
</div>
{{-- Title - Prominenter ohne Bild --}}
<h3
class="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-5 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{ $title }}
</h3>
{{-- Teaser as Quote/Highlight --}}
<blockquote
class="text-base leading-relaxed text-zinc-700 dark:text-zinc-300 line-clamp-5 mb-6 flex-grow italic border-l-4 border-[var(--color-primary)] pl-4">
{{ $teaser }}
</blockquote>
{{-- Company Footer --}}
<div
class="flex items-center justify-between pt-5 border-t border-[var(--color-primary)]/10 group-hover:border-[var(--color-primary)]/20 transition-all duration-300">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
PDF
</span>
@endif
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $company }}</span>
</div>
<svg class="h-5 w-5 transition-transform group-hover:translate-x-2" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</div>
<a
href="/release/{{ $slug }}"
class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-[var(--color-primary)] transition-colors group-hover:text-[var(--color-primary)]"
>
Lesen
</a>
</div>
</div>
</article>
@endif
</article>
</a>

View file

@ -0,0 +1,60 @@
@props([
'release',
])
@php
$categoryTranslation = $release->category?->translations->firstWhere('locale', 'de')
?? $release->category?->translations->first();
$categoryName = $categoryTranslation?->name;
$categoryUrl = $categoryTranslation?->slug ? route('kategorie', ['slug' => $categoryTranslation->slug]) : null;
$companyName = $release->company?->name ?: 'Unternehmensmeldung';
$teaser = \Illuminate\Support\Str::of(strip_tags((string) $release->text))->squish()->limit(220);
$image = $release->images->first();
$imageUrl = $image?->variantUrl('card') ?? $image?->url();
@endphp
<article class="group rounded-2xl border border-black/10 bg-white p-5 transition hover:border-[#cf3628]/30 hover:shadow-sm dark:border-white/10 dark:bg-zinc-950">
<div class="grid gap-5 sm:grid-cols-[minmax(0,1fr)_11rem]">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
@if ($categoryName && $categoryUrl)
<a href="{{ $categoryUrl }}" class="rounded-full bg-[#cf3628]/10 px-2.5 py-1 text-[#a92c25] hover:bg-[#cf3628]/15">
{{ $categoryName }}
</a>
@elseif ($categoryName)
<span class="rounded-full bg-[#cf3628]/10 px-2.5 py-1 text-[#a92c25]">{{ $categoryName }}</span>
@endif
@if ($release->published_at)
<time datetime="{{ $release->published_at->toDateString() }}">
{{ $release->published_at->format('d.m.Y') }}
</time>
@endif
</div>
<h3 class="mt-3 text-xl font-semibold leading-tight text-zinc-950 group-hover:text-[#cf3628] dark:text-white">
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}">
{{ $release->title }}
</a>
</h3>
<p class="mt-3 text-sm leading-6 text-zinc-600 dark:text-zinc-300">
{{ $teaser }}
</p>
<div class="mt-4 flex flex-wrap items-center gap-3 text-sm text-zinc-500 dark:text-zinc-400">
<span class="font-medium text-zinc-700 dark:text-zinc-200">{{ $companyName }}</span>
<span aria-hidden="true">/</span>
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}" class="font-semibold text-[#cf3628] hover:text-[#a92c25]">
Meldung lesen
</a>
</div>
</div>
@if ($imageUrl)
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}" class="block overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-900">
<img src="{{ $imageUrl }}" alt="{{ $image?->title ?: $release->title }}" class="h-36 w-full object-cover transition duration-300 group-hover:scale-105 sm:h-full" loading="lazy">
</a>
@endif
</div>
</article>

View file

@ -10,105 +10,152 @@
'imageUrl' => null,
])
@php
// Map industry to Heroicon
$industryIcons = [
'IT & Digitalisierung' => 'cpu-chip',
'Industrie & Technik' => 'cog-6-tooth',
'Finanzen & Versicherungen' => 'currency-dollar',
'Handel & E-Commerce' => 'shopping-cart',
'Bauen & Immobilien' => 'building-office',
'Mobilität & Logistik' => 'truck',
'Energie & Umwelt' => 'bolt',
'Medizin & Gesundheit' => 'heart',
'Personal & HR' => 'user-group',
'Marketing, PR & Medien' => 'megaphone',
'Recht & Steuern' => 'scale',
'Politik, Verbände & NGOs' => 'flag',
'Wissenschaft & Forschung' => 'beaker',
'Lifestyle' => 'sparkles',
'Tourismus & Kultur' => 'globe-alt',
];
$iconName = $industryIcons[$industry] ?? 'building-office';
$iconPath = "/heroicons/optimized/24/outline/{$iconName}.svg";
// Content Type Badge Classes
$contentTypeClasses = match ($contentType) {
'ANALYSE' => 'bg-[var(--color-primary)] text-white',
'INTERVIEW' => 'bg-[var(--color-secondary)] text-white',
default => 'bg-white/95 text-[var(--color-primary)] border border-[var(--color-primary)]/20',
};
$badgeExtraClasses = $imageUrl ? 'shadow-md backdrop-blur-sm' : 'shadow-sm';
@endphp
<a href="/release/{{ $slug }}" class="group block">
<article class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-[var(--color-primary)]/30 shadow-sm h-full flex flex-col">
<article
class="bg-gradient-to-br from-[var(--color-zinc-100)]/50 to-[var(--color-zinc-100)]/25 dark:from-[var(--color-zinc-800)] dark:to-[var(--color-zinc-800)]/50 rounded-xl border border-[var(--color-primary)]/20 dark:border-zinc-800 overflow-hidden transition-all duration-300 hover:shadow-xl hover:border-[var(--color-primary)]/30 hover:translate-y-[-2px] shadow-sm h-full flex flex-col">
@if($imageUrl)
<!-- Card mit Bild -->
<!-- Image Preview -->
<div class="relative h-52 bg-gradient-to-br from-gray-100 to-gray-50 dark:from-gray-800 dark:to-gray-900 overflow-hidden">
<img
src="{{ $imageUrl }}"
alt="{{ $title }}"
@if ($imageUrl)
{{-- Card mit Bild --}}
<div
class="relative h-46 bg-gradient-to-br from-zinc-100 to-zinc-50 dark:from-zinc-800 dark:to-zinc-900 overflow-hidden">
<img src="{{ $imageUrl }}" alt="{{ $title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
loading="lazy" />
<!-- Content Type Label - Positioned on Image -->
{{-- Content Type Label --}}
<div class="absolute top-4 left-4">
<span class="inline-block text-[10px] font-bold tracking-widest uppercase px-3 py-1.5
@if($contentType === 'ANALYSE')
bg-[var(--color-primary)] text-white
@elseif($contentType === 'INTERVIEW')
bg-[var(--color-secondary)] text-white
@else
bg-white/95 text-[var(--color-primary)] border border-[var(--color-primary)]/20
@endif
shadow-lg backdrop-blur-sm rounded">
<span
class="inline-block text-[10px] font-bold tracking-widest uppercase px-3 py-1.5 {{ $contentTypeClasses }} {{ $badgeExtraClasses }} rounded">
{{ $contentType }}
</span>
</div>
</div>
<!-- Content with increased padding -->
{{-- Content --}}
<div class="p-7 flex flex-col flex-grow">
<!-- Meta Info - More spacing -->
<div class="flex items-center gap-3 mb-4 text-xs text-gray-500 dark:text-gray-500">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $industry }}</span>
<span class="text-gray-300 dark:text-gray-700"></span>
<time datetime="{{ $date }}" class="text-gray-600 dark:text-gray-400">{{ $date }}</time>
{{-- Meta Info --}}
<div class="flex items-center justify-between gap-2 mb-4 text-xs text-zinc-500 dark:text-zinc-400">
<div class="flex items-center gap-2">
<div class="industry-icon-badge">
<img src="{{ $iconPath }}" alt="{{ $industry }}" class="h-4 w-4" />
</div>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $industry }}</span>
</div>
<time datetime="{{ $date }}"
class="text-zinc-600 dark:text-zinc-400">{{ $date }}</time>
</div>
<!-- Title - Larger, more space -->
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100 line-clamp-2 mb-4 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{-- Title --}}
<h3
class="text-xl font-bold text-zinc-900 dark:text-zinc-100 line-clamp-2 mb-4 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{ $title }}
</h3>
<!-- Teaser - More generous line-height -->
<p class="text-[15px] leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 mb-5 flex-grow">
{{-- Teaser --}}
<p class="text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400 line-clamp-3 mb-5 flex-grow">
{{ $teaser }}
</p>
<!-- Company at bottom -->
<div class="flex items-center gap-2 pt-4 border-t border-gray-100 dark:border-gray-800">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
{{-- Company Footer --}}
<div class="flex items-center justify-between pt-5 border-t border-[var(--color-primary)]/10">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $company }}</span>
</div>
<svg class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $company }}</span>
</div>
</div>
@else
<!-- Card ohne Bild - Textbasiert mit farbigem Hintergrund -->
<div class="relative bg-gradient-to-br from-[var(--color-primary)]/5 via-[var(--color-secondary)]/5 to-[var(--color-primary)]/10 dark:from-[var(--color-primary)]/10 dark:via-[var(--color-secondary)]/10 dark:to-[var(--color-primary)]/20 p-8 flex flex-col min-h-[400px]">
{{-- Card ohne Bild - Textbasiert mit farbigem Hintergrund --}}
<div class="relative p-8 flex flex-col h-full">
<!-- Content Type Label - Top -->
<div class="mb-6">
<span class="inline-block text-[10px] font-bold tracking-widest uppercase px-3 py-1.5
@if($contentType === 'ANALYSE')
bg-[var(--color-primary)] text-white
@elseif($contentType === 'INTERVIEW')
bg-[var(--color-secondary)] text-white
@else
bg-white text-[var(--color-primary)] border border-[var(--color-primary)]/20
@endif
shadow-sm rounded">
{{-- Content Type Label --}}
<div class="mb-6 mt-0">
<span
class="inline-block text-[10px] font-bold tracking-widest uppercase px-3 py-1.5 {{ $contentTypeClasses }} {{ $badgeExtraClasses }} rounded">
{{ $contentType }}
</span>
</div>
<!-- Meta Info -->
<div class="flex items-center gap-3 mb-5 text-xs text-gray-500 dark:text-gray-400">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $industry }}</span>
<span class="text-gray-300 dark:text-gray-600"></span>
<time datetime="{{ $date }}" class="text-gray-600 dark:text-gray-400">{{ $date }}</time>
{{-- Meta Info --}}
<div class="flex items-center justify-between gap-2 mb-5 text-xs text-zinc-500 dark:text-zinc-400">
<div class="flex items-center gap-2">
<div class="industry-icon-badge">
<img src="{{ $iconPath }}" alt="{{ $industry }}" class="h-4 w-4" />
</div>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $industry }}</span>
</div>
<time datetime="{{ $date }}"
class="text-zinc-600 dark:text-zinc-400">{{ $date }}</time>
</div>
<!-- Title - Prominenter ohne Bild -->
<h3 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-5 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{-- Title - Prominenter ohne Bild --}}
<h3
class="text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-5 group-hover:text-[var(--color-primary)] transition-colors leading-tight">
{{ $title }}
</h3>
<!-- Teaser as Quote/Highlight - Mehr Zeilen ohne Bild -->
<blockquote class="text-base leading-relaxed text-gray-700 dark:text-gray-300 line-clamp-5 mb-6 flex-grow italic border-l-4 border-[var(--color-primary)] pl-4">
{{-- Teaser as Quote/Highlight --}}
<blockquote
class="text-base leading-relaxed text-zinc-700 dark:text-zinc-300 line-clamp-5 mb-6 flex-grow italic border-l-4 border-[var(--color-primary)] pl-4">
{{ $teaser }}
</blockquote>
<!-- Company at bottom -->
<div class="flex items-center gap-2 pt-5 border-t border-[var(--color-primary)]/10">
<svg class="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
{{-- Company Footer --}}
<div class="flex items-center justify-between pt-5 border-t border-[var(--color-primary)]/10">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $company }}</span>
</div>
<svg class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $company }}</span>
</div>
</div>
@endif

View file

@ -0,0 +1,32 @@
@props([
'submitHref' => null,
'pricingHref' => null,
])
@php
$submitHref ??= route('veroeffentlichen');
$pricingHref ??= route('preise');
@endphp
<section class="bg-topbar-grad text-ink-on-dark p-6">
<div class="eyebrow on-dark mb-2">Für Unternehmen</div>
<h3 class="font-serif text-[20px] font-semibold m-0 mb-2 tracking-[-0.2px] leading-[1.2] text-white">
Veröffentlichen Sie Ihre Pressemitteilung
</h3>
<p class="text-[12.5px] leading-[1.5] m-0 mb-4 text-white/85">
Reichweite in DACH · Redaktionelle Prüfung · Strukturierte Distribution
</p>
<a href="{{ $submitHref }}"
class="w-full inline-flex items-center justify-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold bg-brand text-white rounded-[2px] cursor-pointer hover:bg-brand-deep transition-colors">
Jetzt einreichen
</a>
<a href="{{ $pricingHref }}"
class="flex items-center justify-center gap-1.5 w-full mt-3 py-2 text-[12px] font-medium text-white/80 cursor-pointer hover:text-white underline underline-offset-[3px] decoration-white/30 hover:decoration-white/70 transition-colors">
Tarife &amp; Pakete ansehen
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 6h8M6 2l4 4-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</section>

View file

@ -0,0 +1,25 @@
@props(['stats' => []])
<section class="max-w-layout mx-auto mt-12 px-8">
<div class="grid items-center gap-7 p-6 bg-bg-card-warm border border-bg-rule-strong"
style="grid-template-columns: auto 1fr auto;">
<div class="w-11 h-11 rounded-full flex-shrink-0 bg-white border border-bg-rule-strong flex items-center justify-center text-brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 2l9 4v6c0 5-3.5 9-9 10-5.5-1-9-5-9-10V6z" stroke="currentColor" stroke-width="1.6" fill="none" />
<path d="M8 12l3 3 5-6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div>
<div class="eyebrow mb-1">Redaktioneller Qualitätsstandard</div>
<div class="text-[13.5px] text-ink leading-[1.5] font-medium">
Alle Pressemitteilungen werden redaktionell geprüft.
<span class="text-ink-2 font-normal">
Mindestqualität gewährleistet durch unseren Content-Score eine Bewertung von Quellenqualität, Verifizierungsstatus und redaktioneller Relevanz.
</span>
</div>
</div>
<a href="{{ route('faq') }}" class="text-[12px] font-semibold text-brand whitespace-nowrap underline underline-offset-[3px] decoration-brand/40 cursor-pointer hover:text-brand-deep hover:decoration-brand transition-colors">
Redaktionsrichtlinien
</a>
</div>
</section>

View file

@ -0,0 +1,32 @@
@props([
'title',
'subtitle' => null,
'size' => 'large', // large, medium, small
])
@php
$titleClasses = match($size) {
'large' => 'text-3xl md:text-4xl',
'medium' => 'text-2xl md:text-3xl',
'small' => 'text-xl md:text-2xl',
default => 'text-2xl md:text-3xl',
};
$indicatorClasses = match($size) {
'large' => 'w-2 h-10',
'medium' => 'w-1.5 h-8',
'small' => 'w-1 h-6',
default => 'w-1.5 h-8',
};
@endphp
<div class="mb-8">
<h2 class="font-bold text-zinc-900 dark:text-zinc-100 mb-2 flex items-center gap-3 {{ $titleClasses }}">
<span class="{{ $indicatorClasses }} gradient-indicator"></span>
{{ $title }}
</h2>
@if($subtitle)
<p class="text-zinc-600 dark:text-zinc-400 ml-5">{{ $subtitle }}</p>
@endif
</div>

View file

@ -0,0 +1,32 @@
@props([
'categories' => [],
'activeSlug' => null,
])
@php
use App\Services\CategoryService;
@endphp
<x-web.sidebar-widget title="Kategorien">
<div class="space-y-2">
@foreach ($categories as $cat)
@php
$catIconPath = CategoryService::getIconPath($cat['icon']);
$isActive = $cat['slug'] === $activeSlug;
@endphp
<a href="/kategorie/{{ $cat['slug'] }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all group
{{ $isActive
? 'bg-[var(--color-primary)] text-white'
: 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-[var(--color-primary)] hover:translate-x-1' }}">
<img src="{{ $catIconPath }}" alt="{{ $cat['name'] }}"
class="h-4 w-4 {{ $isActive ? 'brightness-0 invert' : '' }}">
<span class="flex-1">{{ $cat['name'] }}</span>
<span class="text-xs {{ $isActive ? 'opacity-75' : 'opacity-50' }}">
{{ $cat['count'] }}
</span>
</a>
@endforeach
</div>
</x-web.sidebar-widget>

View file

@ -0,0 +1,34 @@
@props([
'companies' => [],
'title' => 'Top Unternehmen',
])
@php
// Default companies wenn keine übergeben werden
if (empty($companies)) {
$companies = [
['name' => 'TechVision Analytics', 'initial' => 'T', 'url' => '#'],
['name' => 'CloudTech Research', 'initial' => 'C', 'url' => '#'],
['name' => 'CyberSafe Europe', 'initial' => 'C', 'url' => '#'],
['name' => 'DevTech Insights', 'initial' => 'D', 'url' => '#'],
['name' => 'QuantumTech Germany', 'initial' => 'Q', 'url' => '#'],
];
}
@endphp
<x-web.sidebar-widget title="Top Unternehmen">
<div class="space-y-2">
@foreach ($companies as $company)
<a href="{{ $company['url'] ?? '#' }}"
class="flex items-center gap-3 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800 p-2 rounded-lg transition-colors">
<div
class="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)]/10 to-[var(--color-secondary)]/10 rounded flex items-center justify-center flex-shrink-0">
<span class="text-xs font-bold text-[var(--color-primary)]">{{ $company['initial'] }}</span>
</div>
<span class="text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors">
{{ $company['name'] }}
</span>
</a>
@endforeach
</div>
</x-web.sidebar-widget>

View file

@ -0,0 +1,23 @@
@props([
'title' => 'Newsletter',
'description' => 'Erhalten Sie die neuesten Pressemitteilungen direkt in Ihr Postfach',
'buttonText' => 'Jetzt abonnieren',
])
<x-web.sidebar-widget :title="$title">
<div class="text-center">
<svg class="h-10 w-10 text-[var(--color-primary)] mx-auto mb-3" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
{{ $description }}
</p>
<button class="btn-primary w-full py-3 text-sm">
{{ $buttonText }}
</button>
</div>
</x-web.sidebar-widget>

View file

@ -0,0 +1,24 @@
@props([
'title' => 'RSS-Feed',
'description' => 'Bleiben Sie über neue Meldungen auf dem Laufenden',
'buttonText' => 'RSS abonnieren',
])
<x-web.sidebar-widget :title="$title">
<div class="text-center">
<svg class="h-10 w-10 text-[var(--color-secondary)] mx-auto mb-3" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z">
</path>
</svg>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
{{ $description }}
</p>
<button
class="w-full px-6 py-3 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-all">
{{ $buttonText }}
</button>
</div>
</x-web.sidebar-widget>

View file

@ -0,0 +1,21 @@
@props([
'title' => null,
'icon' => null,
])
<div class="sidebar-widget">
@if ($title)
<div class="sidebar-widget-header">
@if ($icon)
<div class="w-5 h-5 text-[var(--color-primary)]">
{!! $icon !!}
</div>
@endif
<h3 class="sidebar-widget-title">{{ $title }}</h3>
</div>
@endif
<div class="sidebar-widget-content">
{{ $slot }}
</div>
</div>

View file

@ -0,0 +1,84 @@
@props([
'brand' => null,
])
@php
$themeKey = config('app.theme');
$brand = $brand ?? config('domains.domains.'.$themeKey.'.brand', [
'name' => 'businessportal',
'accent' => '24',
'tagline_short' => 'Pressemitteilungen · DACH',
'tagline_long' => 'Veröffentlichungs-Portal für redaktionell geprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.',
'footer_legal' => '© :year businessportal24.com · Alle Rechte vorbehalten',
'about_label' => 'Über businessportal24',
]);
$brandName = $brand['name'] ?? 'businessportal';
$brandAccent = $brand['accent'] ?? '';
$brandTagline = $brand['tagline_short'] ?? 'Pressemitteilungen · DACH';
$brandTaglineLong = $brand['tagline_long'] ?? 'Pressemitteilungen aus dem DACH-Raum.';
$aboutLabel = $brand['about_label'] ?? ('Über '.$brandName.$brandAccent);
$legal = str_replace(':year', (string) now()->format('Y'), $brand['footer_legal'] ?? '© :year · Alle Rechte vorbehalten');
@endphp
<footer class="mt-16 bg-topbar-grad text-ink-on-dark">
<div class="max-w-layout mx-auto px-8 py-12 grid gap-10 grid-cols-1 md:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1fr_1fr]">
<div>
<a href="{{ route('home') }}" class="block cursor-pointer group" aria-label="{{ $brandName }}{{ $brandAccent }} Startseite">
<div class="font-serif text-[24px] font-semibold leading-none tracking-[-0.5px] text-white group-hover:text-white/80 transition-colors">
{{ $brandName }}@if ($brandAccent)<span class="text-brand">{{ $brandAccent }}</span>@endif
</div>
<div class="eyebrow mt-2 text-[9.5px] tracking-[0.18em] text-ink-on-dark-muted">
{{ $brandTagline }}
</div>
</a>
<p class="text-[12.5px] text-white/55 leading-[1.55] mt-4 max-w-[320px]">
{{ $brandTaglineLong }}
</p>
</div>
<div>
<div class="eyebrow mb-3.5">Pressemitteilungen</div>
<ul class="space-y-2 text-[13px] text-white/75 list-none p-0 m-0">
<li><a href="{{ route('kategorien') }}" class="cursor-pointer hover:text-white transition-colors">Alle Branchen</a></li>
<li><a href="#" class="cursor-pointer hover:text-white transition-colors">Ad-Hoc-Meldungen</a></li>
<li><a href="{{ route('kategorien') }}#termine" class="cursor-pointer hover:text-white transition-colors">Termine &amp; Events</a></li>
<li><a href="{{ route('newsrooms') }}" class="cursor-pointer hover:text-white transition-colors">Newsrooms</a></li>
<li><a href="{{ route('kategorien') }}" class="cursor-pointer hover:text-white transition-colors">Branchen-Index</a></li>
</ul>
</div>
<div>
<div class="eyebrow mb-3.5">Für Unternehmen</div>
<ul class="space-y-2 text-[13px] text-white/75 list-none p-0 m-0">
<li><a href="{{ route('veroeffentlichen') }}" class="cursor-pointer hover:text-white transition-colors">Veröffentlichen</a></li>
<li><a href="{{ route('preise') }}" class="cursor-pointer hover:text-white transition-colors">Tarife &amp; Pakete</a></li>
<li><a href="#" class="cursor-pointer hover:text-white transition-colors">Verifizierter Newsroom</a></li>
<li><a href="{{ route('api') }}" class="cursor-pointer hover:text-white transition-colors">API &amp; RSS</a></li>
<li><a href="#" class="cursor-pointer hover:text-white transition-colors">Mediendaten</a></li>
</ul>
</div>
<div>
<div class="eyebrow mb-3.5">Unternehmen</div>
<ul class="space-y-2 text-[13px] text-white/75 list-none p-0 m-0">
<li><a href="{{ route('ueber-uns') }}" class="cursor-pointer hover:text-white transition-colors">{{ $aboutLabel }}</a></li>
<li><a href="#" class="cursor-pointer hover:text-white transition-colors">Redaktion</a></li>
<li><a href="{{ route('kontakt') }}" class="cursor-pointer hover:text-white transition-colors">Kontakt</a></li>
<li><a href="{{ route('impressum') }}" class="cursor-pointer hover:text-white transition-colors">Impressum</a></li>
<li><a href="{{ route('datenschutz') }}" class="cursor-pointer hover:text-white transition-colors">Datenschutz</a></li>
</ul>
</div>
</div>
<div class="border-t border-white/10">
<div class="max-w-layout mx-auto px-8 py-5 flex items-center justify-between gap-4 text-[11.5px] text-white/50 flex-wrap">
<span>{{ $legal }}</span>
<span class="flex items-center gap-4 flex-wrap">
<a href="{{ route('agb') }}" class="cursor-pointer hover:text-white transition-colors">AGB</a>
<a href="#" class="cursor-pointer hover:text-white transition-colors">Cookie-Einstellungen</a>
<a href="{{ route('datenschutz') }}" class="cursor-pointer hover:text-white transition-colors">DSGVO</a>
</span>
</div>
</div>
</footer>

View file

@ -0,0 +1,312 @@
@props([
'activeRegion' => 'DE',
'language' => 'Deutsch',
'languageShort' => 'DE',
'navigation' => null,
'ticker' => [],
'marketIndex' => null,
'asideSlides' => null,
'brand' => null,
])
@php
$themeKey = config('app.theme');
$brand = $brand ?? config('domains.domains.'.$themeKey.'.brand', [
'name' => 'businessportal',
'accent' => '24',
'tagline_short' => 'Pressemitteilungen · DACH',
]);
$brandName = $brand['name'] ?? 'businessportal';
$brandAccent = $brand['accent'] ?? '';
$brandTagline = $brand['tagline_short'] ?? 'Pressemitteilungen · DACH';
@endphp
@php
\Carbon\Carbon::setLocale('de');
$today = now();
$regions = [
['code' => 'DE', 'flag' => '🇩🇪'],
['code' => 'AT', 'flag' => '🇦🇹'],
['code' => 'CH', 'flag' => '🇨🇭'],
['code' => 'EN', 'flag' => '🌐'],
];
$navigation ??= [
['label' => 'Startseite', 'href' => route('home'), 'active' => true],
['label' => 'Wirtschaft', 'href' => route('kategorien')],
['label' => 'Technologie', 'href' => route('kategorien')],
['label' => 'Finanzen', 'href' => route('kategorien')],
['label' => 'Industrie', 'href' => route('kategorien')],
['label' => 'Energie', 'href' => route('kategorien')],
['label' => 'Gesundheit', 'href' => route('kategorien')],
['label' => 'Handel', 'href' => route('kategorien')],
['label' => 'Immobilien', 'href' => route('kategorien')],
['label' => 'Mobilität', 'href' => route('kategorien')],
['label' => 'Alle Rubriken', 'href' => route('kategorien')],
];
$activeLabel = collect($navigation)->firstWhere('active', true)['label'] ?? 'Menü';
@endphp
<header>
{{-- Top Utility Bar (dunkel) --}}
<div id="topbar" class="bg-topbar-grad text-ink-on-dark-2 border-b border-black">
<div class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 flex items-stretch text-[11.5px] tracking-wide">
<span class="flex items-center pr-3 sm:pr-4 py-2 whitespace-nowrap border-r border-ink-on-dark-rule text-[10.5px] sm:text-[11.5px]">
<span class="hidden sm:inline">{{ ucfirst($today->isoFormat('dd, D. MMMM YYYY')) }}</span>
<span class="sm:hidden">{{ ucfirst($today->isoFormat('dd, D. MMM')) }}</span>
</span>
<span class="hidden md:flex items-center px-3.5 text-[9.5px] uppercase font-semibold tracking-[0.16em] text-ink-3 whitespace-nowrap">
Ausgabe
</span>
@foreach ($regions as $region)
<button type="button"
class="flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2.5 py-2 text-[11px] sm:text-[11.5px] whitespace-nowrap -mb-px border-b-2 cursor-pointer transition-colors
@if ($region['code'] === $activeRegion) font-semibold text-ink-on-dark bg-white/[0.06] border-brand
@else font-medium text-ink-on-dark-2 border-transparent hover:text-ink-on-dark hover:bg-white/[0.04] @endif"
aria-label="Region {{ $region['code'] }}"
aria-pressed="{{ $region['code'] === $activeRegion ? 'true' : 'false' }}">
<span class="text-[11px] sm:text-[12px] leading-none">{{ $region['flag'] }}</span>
<span>{{ $region['code'] }}</span>
</button>
@endforeach
<span class="flex-1"></span>
{{-- Sprache: mobil verkürzt; ab lg mit Newsletter/RSS/API --}}
<span class="flex items-center gap-3 sm:gap-4 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="opacity-60" aria-hidden="true">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1" />
<path d="M1 6h10M6 1c2 1.5 2 8.5 0 10M6 1c-2 1.5-2 8.5 0 10" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
<strong class="text-ink-on-dark font-semibold">
<span class="hidden lg:inline">{{ $language }}</span>
<span class="lg:hidden">{{ $languageShort }}</span>
</strong>
</span>
<span class="hidden lg:inline-block w-px h-[14px] bg-ink-on-dark-rule"></span>
<a href="#newsletter" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">Newsletter</a>
<a href="#rss" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">RSS</a>
<a href="{{ route('api') }}" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">API</a>
</span>
</div>
</div>
{{-- Header (Logo + Suche + Buttons + Burger) --}}
<div id="header" class="header-normal bg-bg border-b border-bg-rule"
x-data="{ searchOpen: false }"
@keydown.escape.window="searchOpen = false">
<div class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 py-3 lg:py-[18px] flex items-center gap-3 sm:gap-4 lg:gap-6">
<a href="{{ route('home') }}" class="block cursor-pointer group flex-shrink-0" aria-label="{{ $brandName }}{{ $brandAccent }} Startseite">
<div class="font-serif text-[22px] sm:text-[24px] lg:text-[28px] font-semibold leading-none tracking-[-0.5px] text-ink group-hover:text-brand transition-colors">
{{ $brandName }}@if ($brandAccent)<span class="text-brand">{{ $brandAccent }}</span>@endif
</div>
<div class="eyebrow muted mt-1 text-[9.5px] tracking-[0.18em] hidden sm:block">
{{ $brandTagline }}
</div>
</a>
{{-- Suchfeld nur ab md sichtbar --}}
<form action="{{ route('suche') }}" method="get" class="hidden md:block flex-1 max-w-[460px]">
<label class="sr-only" for="site-search">Pressemitteilungen, Unternehmen, Branchen, ISIN durchsuchen</label>
<div class="flex items-center gap-2.5 px-3.5 py-2.5 border border-bg-rule bg-bg-elev text-[13px] text-ink-3 rounded-[2px] focus-within:border-brand transition-colors">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<input id="site-search" type="search" name="q"
placeholder="Pressemitteilungen, Unternehmen, Branchen, ISIN…"
class="flex-1 bg-transparent border-0 p-0 text-[13px] text-ink placeholder:text-ink-3 focus:outline-none focus:ring-0 min-w-0">
<span class="hidden xl:inline-block text-[10.5px] text-ink-4 border border-bg-rule px-1.5 py-0.5 rounded-[2px] font-mono">⌘K</span>
</div>
<div class="hidden lg:flex justify-end mt-1.5">
<a href="{{ route('suche') }}" class="inline-flex items-center gap-1.5 text-[11.5px] text-ink-3 underline underline-offset-2 decoration-[#1C1A17]/40 cursor-pointer hover:text-ink hover:decoration-brand transition-colors">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 3h8M3.5 6h5M5 9h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
Erweiterte Suche
</a>
</div>
</form>
<span class="flex-1 md:hidden"></span>
{{-- Such-Icon nur auf mobile --}}
<button type="button"
@click="searchOpen = true"
class="md:hidden inline-flex items-center justify-center w-10 h-10 text-ink rounded-[2px] cursor-pointer hover:bg-bg-elev transition-colors"
aria-label="Suche öffnen">
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
{{-- Anmelden --}}
<a href="#anmelden"
class="inline-flex items-center gap-2 px-2 sm:px-3 lg:px-4 py-2 lg:py-2.5 text-[13px] font-semibold text-ink rounded-[2px] cursor-pointer hover:text-brand transition-colors"
aria-label="Anmelden">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" class="sm:hidden" aria-hidden="true">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.8" />
<path d="M4 21c0-4 4-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<span class="hidden sm:inline">Anmelden</span>
</a>
{{-- Veröffentlichen --}}
<a href="{{ route('veroeffentlichen') }}"
class="inline-flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 lg:px-[18px] py-2 lg:py-2.5 text-[12.5px] sm:text-[13px] font-semibold text-white bg-brand cursor-pointer hover:bg-brand-deep rounded-[2px] transition-colors whitespace-nowrap"
aria-label="Pressemitteilung veröffentlichen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" class="sm:hidden" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
</svg>
<span class="sm:hidden">PM</span>
<span class="hidden sm:inline">Veröffentlichen</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="hidden sm:inline" aria-hidden="true">
<path d="M3 6h6M6 3l3 3-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</div>
{{-- Such-Overlay --}}
<div x-show="searchOpen"
x-transition.opacity.duration.150ms
class="fixed inset-0 z-50 bg-ink/70 backdrop-blur-sm"
@click.self="searchOpen = false"
style="display: none;">
<div x-show="searchOpen"
x-transition.duration.200ms
class="bg-bg border-b border-bg-rule shadow-xl">
<div class="max-w-layout mx-auto px-4 sm:px-6 py-6">
<div class="flex items-start justify-between mb-4">
<div>
<div class="eyebrow mb-1">Suche</div>
<h2 class="font-serif text-[22px] font-semibold text-ink">Pressemitteilungen durchsuchen</h2>
</div>
<button type="button"
@click="searchOpen = false"
class="inline-flex items-center justify-center w-10 h-10 text-ink rounded-[2px] cursor-pointer hover:bg-bg-elev transition-colors"
aria-label="Suche schließen">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M6 18L18 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<form action="{{ route('suche') }}" method="get">
<label class="sr-only" for="site-search-mobile">Pressemitteilungen durchsuchen</label>
<div class="flex items-center gap-2.5 px-4 py-3.5 border border-bg-rule-strong bg-bg-elev text-[15px] text-ink-3 rounded-[2px]">
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<input id="site-search-mobile" type="search" name="q"
placeholder="Pressemitteilungen, Unternehmen, Branchen, ISIN…"
x-ref="searchInput"
x-init="$watch('searchOpen', value => value && setTimeout(() => $refs.searchInput.focus(), 50))"
class="flex-1 bg-transparent border-0 p-0 text-[15px] text-ink placeholder:text-ink-3 focus:outline-none focus:ring-0 min-w-0">
<button type="submit"
class="px-3 py-1.5 text-[12px] font-semibold bg-brand text-white rounded-[2px] cursor-pointer hover:bg-brand-deep transition-colors">
Suchen
</button>
</div>
<div class="mt-3 flex justify-between text-[11.5px] text-ink-3">
<a href="{{ route('suche') }}" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-ink transition-colors underline underline-offset-2 decoration-[#1C1A17]/40">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 3h8M3.5 6h5M5 9h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
Erweiterte Suche
</a>
<span class="hidden sm:inline-flex items-center gap-1.5">
Drücken Sie
<kbd class="font-mono text-[10.5px] text-ink-4 border border-bg-rule px-1.5 py-0.5 rounded-[2px]">Esc</kbd>
zum Schließen
</span>
</div>
</form>
</div>
</div>
</div>
</div>
{{-- Main Navigation (Underline-Tabs Desktop, Burger Mobile) --}}
<nav class="bg-bg border-b border-bg-rule relative"
x-data="{ menuOpen: false }"
@keydown.escape.window="menuOpen = false"
@click.outside="menuOpen = false"
aria-label="Hauptnavigation">
{{-- Desktop: horizontale Tabs --}}
<div class="hidden lg:flex max-w-layout mx-auto px-8 items-stretch">
@foreach ($navigation as $item)
<a href="{{ $item['href'] }}"
class="px-4 py-3.5 text-[13.5px] whitespace-nowrap border-b-2 -mb-px cursor-pointer transition-colors
@if ($item['active'] ?? false) font-semibold text-ink border-brand
@else font-medium text-ink-2 border-transparent hover:text-ink hover:border-bg-rule-strong/30 @endif">
{{ $item['label'] }}
</a>
@endforeach
</div>
{{-- Mobile: aktive Kategorie + Burger --}}
<div class="lg:hidden max-w-layout mx-auto px-4 sm:px-6">
<button type="button"
@click="menuOpen = !menuOpen"
class="flex items-center justify-between w-full py-3 cursor-pointer group"
:aria-expanded="menuOpen.toString()"
aria-controls="mobile-nav-menu"
aria-label="Hauptmenü öffnen">
<span class="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-ink"
:class="{ 'hidden': menuOpen }" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-ink hidden"
:class="{ 'hidden': !menuOpen, '!block': menuOpen }" aria-hidden="true">
<path d="M6 6l12 12M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span class="text-[10px] font-bold tracking-[0.18em] uppercase text-ink-3 group-hover:text-ink transition-colors">Rubrik</span>
<span class="font-serif text-[15px] font-semibold text-ink leading-none">{{ $activeLabel }}</span>
</span>
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" class="text-ink-3 transition-transform"
:class="{ 'rotate-180': menuOpen }" aria-hidden="true">
<path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
{{-- Mobile Dropdown --}}
<div id="mobile-nav-menu"
x-show="menuOpen"
x-transition.origin.top
x-cloak
class="lg:hidden border-t border-bg-rule bg-bg-elev absolute left-0 right-0 z-40 shadow-lg"
style="display: none;">
<div class="max-w-layout mx-auto px-4 sm:px-6 py-2">
@foreach ($navigation as $item)
<a href="{{ $item['href'] }}"
class="flex items-center justify-between px-3 py-3 text-[14px] border-b border-bg-rule last:border-b-0 cursor-pointer transition-colors
@if ($item['active'] ?? false) font-semibold text-brand bg-brand/[0.04]
@else font-medium text-ink-2 hover:text-ink hover:bg-bg @endif">
<span>{{ $item['label'] }}</span>
@if ($item['active'] ?? false)
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 6l3 3 5-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@else
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="text-ink-3" aria-hidden="true">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</a>
@endforeach
</div>
</div>
</nav>
<x-web.live-ticker :items="$ticker" :market-index="$marketIndex" :aside-slides="$asideSlides" />
</header>

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Bitte Passwort setzen {{ config('app.name') }}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.btn { display: inline-block; background: #2563eb; color: #fff; padding: 12px 28px; border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0; }
.note { font-size: 13px; color: #666; border-top: 1px solid #eee; padding-top: 12px; margin-top: 24px; }
</style>
</head>
<body>
<p>Hallo {{ $user->name }},</p>
<p>
wir haben unser Presseportal auf ein neues System migriert.
Ihr Account wurde übertragen Sie müssen jedoch einmalig ein neues Passwort setzen,
um sich künftig einzuloggen.
</p>
<p>Klicken Sie auf den folgenden Link, um Ihr Passwort festzulegen:</p>
<p><a class="btn" href="{{ $resetUrl }}">Passwort jetzt setzen</a></p>
<p>
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
<small>{{ $resetUrl }}</small>
</p>
<div class="note">
<p>Der Link ist <strong>{{ $expiresInMinutes }} Minuten</strong> gültig.</p>
<p>
Falls Sie keinen Account bei uns haben oder diese E-Mail nicht erwartet haben,
ignorieren Sie diese Nachricht bitte. Kein Handlungsbedarf.
</p>
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
</div>
</body>
</html>

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Ihr Login-Link</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5;">
<p>Hallo {{ $user->name }},</p>
<p>hier ist Ihr Einmal-Link fuer den Login in Ihr Kundenkonto.</p>
<p>
<a href="{{ $loginUrl }}">Jetzt einloggen</a>
</p>
<p>Der Link ist gueltig bis {{ $expiresAt }} Uhr und kann nur einmal verwendet werden.</p>
<p>Falls Sie den Login nicht angefordert haben, ignorieren Sie diese E-Mail bitte.</p>
</body>
</html>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Ihre Pressemitteilung wurde veröffentlicht</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.btn { display: inline-block; background: #16a34a; color: #fff; padding: 12px 28px; border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0; }
.title { font-size: 18px; font-weight: bold; margin: 12px 0; color: #111; }
.note { font-size: 13px; color: #666; border-top: 1px solid #eee; padding-top: 12px; margin-top: 24px; }
</style>
</head>
<body>
<p>Hallo {{ $user->name }},</p>
<p>Ihre Pressemitteilung wurde geprüft und erfolgreich veröffentlicht.</p>
<p class="title">{{ $pressRelease->title }}</p>
<p><a class="btn" href="{{ $showUrl }}">Pressemitteilung ansehen</a></p>
@if($pressRelease->published_at)
<p>Veröffentlicht am: {{ $pressRelease->published_at->format('d.m.Y H:i') }} Uhr</p>
@endif
<div class="note">
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
</div>
</body>
</html>

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Ihre Pressemitteilung wurde abgelehnt</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.btn { display: inline-block; background: #2563eb; color: #fff; padding: 12px 28px; border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0; }
.title { font-size: 18px; font-weight: bold; margin: 12px 0; color: #111; }
.reason { background: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 16px 0; color: #7f1d1d; }
.note { font-size: 13px; color: #666; border-top: 1px solid #eee; padding-top: 12px; margin-top: 24px; }
</style>
</head>
<body>
<p>Hallo {{ $user->name }},</p>
<p>Ihre eingereichte Pressemitteilung konnte leider nicht veröffentlicht werden.</p>
<p class="title">{{ $pressRelease->title }}</p>
@if($reason)
<div class="reason">
<strong>Ablehnungsgrund:</strong><br>
{{ $reason }}
</div>
@else
<p>Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.</p>
@endif
<p><a class="btn" href="{{ $editUrl }}">Pressemitteilung bearbeiten</a></p>
<div class="note">
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
</div>
</body>
</html>

View file

@ -16,7 +16,7 @@
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
<!-- Styles -->
@vite(['resources/css/portal.css', 'resources/js/app.js'])
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
<!-- Additional Styles -->
@stack('styles')
@ -33,12 +33,12 @@
</div>
<nav class="mt-10 px-6">
<a href="{{ route('admin.dashboard') }}"
class="block py-2.5 px-4 rounded transition duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 {{ request()->routeIs('admin.dashboard') ? 'bg-gray-100 dark:bg-gray-700' : '' }}">
<a href="{{ route('dashboard') }}"
class="block py-2.5 px-4 rounded transition duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 {{ request()->routeIs('dashboard') ? 'bg-gray-100 dark:bg-gray-700' : '' }}">
Dashboard
</a>
<a href="{{ route('admin.settings.profile') }}"
class="block py-2.5 px-4 rounded transition duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 {{ request()->routeIs('admin.settings.profile') ? 'bg-gray-100 dark:bg-gray-700' : '' }}">
<a href="{{ route('settings.profile') }}"
class="block py-2.5 px-4 rounded transition duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 {{ request()->routeIs('settings.profile') ? 'bg-gray-100 dark:bg-gray-700' : '' }}">
Profil
</a>
<!-- Weitere Navigationspunkte hier -->
@ -95,10 +95,10 @@
<!-- Dropdown menu -->
<div class="hidden origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu" id="user-menu">
<a href="{{ route('admin.settings.profile') }}"
<a href="{{ route('settings.profile') }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
role="menuitem">Profil</a>
<a href="{{ route('admin.settings.appearance') }}"
<a href="{{ route('settings.appearance') }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
role="menuitem">Einstellungen</a>
<form method="POST" action="{{ route('logout') }}">
@ -117,9 +117,9 @@
<!-- Mobile menu (off-canvas) -->
<div class="md:hidden hidden" id="mobile-menu">
<div class="px-2 pt-2 pb-3 space-y-1 bg-white dark:bg-gray-800 shadow-lg">
<a href="{{ route('admin.dashboard') }}"
<a href="{{ route('dashboard') }}"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">Dashboard</a>
<a href="{{ route('admin.settings.profile') }}"
<a href="{{ route('settings.profile') }}"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">Profil</a>
<!-- Weitere mobile Navigationspunkte hier -->
</div>

View file

@ -67,7 +67,7 @@
class="text-primary-600 hover:text-primary-900 dark:text-primary-300 dark:hover:text-primary-100">Home</a>
<a href="{{ url('/welcome') }}"
class="text-primary-600 hover:text-primary-900 dark:text-primary-300 dark:hover:text-primary-100">Welcome</a>
<a href="https://pr-copilot.test"
<a href="{{ config('domains.domain_portal_url') }}"
class="text-primary-600 hover:text-primary-900 dark:text-primary-300 dark:hover:text-primary-100">Portal</a>
<!-- Weitere Navigationspunkte hier -->

View file

@ -0,0 +1,219 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\CategoryTranslation;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extends Component
{
#[Validate('required|in:'.Portal::Both->value.','.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
public string $portal = Portal::Both->value;
public ?int $parentId = null;
public bool $isActive = true;
#[Validate('required|string|min:2|max:120')]
public string $nameDe = '';
#[Validate('required|string|min:2|max:120')]
public string $nameEn = '';
public string $slugDe = '';
public string $slugEn = '';
#[Validate('nullable|string|max:1000')]
public string $descriptionDe = '';
#[Validate('nullable|string|max:1000')]
public string $descriptionEn = '';
public function updatedNameDe(): void
{
if (blank($this->slugDe)) {
$this->slugDe = CategoryTranslation::uniqueSlug($this->nameDe, 'de');
}
}
public function updatedNameEn(): void
{
if (blank($this->slugEn)) {
$this->slugEn = CategoryTranslation::uniqueSlug($this->nameEn, 'en');
}
}
public function save(): void
{
$this->validate([
'slugDe' => ['nullable', 'string', 'max:120'],
'slugEn' => ['nullable', 'string', 'max:120'],
'parentId' => ['nullable', 'integer', 'exists:categories,id'],
]);
$this->validate();
DB::transaction(function (): Category {
$category = Category::query()->create([
'parent_id' => $this->parentId ?: null,
'portal' => $this->portal,
'is_active' => $this->isActive,
]);
$slugDe = $this->slugDe !== ''
? CategoryTranslation::uniqueSlug($this->slugDe, 'de')
: CategoryTranslation::uniqueSlug($this->nameDe, 'de');
$slugEn = $this->slugEn !== ''
? CategoryTranslation::uniqueSlug($this->slugEn, 'en')
: CategoryTranslation::uniqueSlug($this->nameEn, 'en');
$category->translations()->createMany([
[
'locale' => 'de',
'name' => $this->nameDe,
'slug' => $slugDe,
'description' => $this->descriptionDe ?: null,
],
[
'locale' => 'en',
'name' => $this->nameEn,
'slug' => $slugEn,
'description' => $this->descriptionEn ?: null,
],
]);
return $category;
});
session()->flash('success', __('Kategorie wurde angelegt.'));
$this->redirect(route('admin.categories.index'), navigate: true);
}
public function with(): array
{
return [
'portalOptions' => Portal::cases(),
'parentOptions' => Category::query()
->with(['translations' => fn ($q) => $q->where('locale', 'de')])
->orderBy('id')
->get(['id', 'parent_id', 'portal']),
];
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<flux:callout color="green" icon="check-circle">{{ session('success') }}</flux:callout>
@endif
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="xl">{{ __('Kategorie anlegen') }}</flux:heading>
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<form wire:submit="save" class="space-y-6">
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Deutsche Übersetzung') }}</flux:heading>
<div class="space-y-4">
<flux:input
wire:model.live.debounce.400ms="nameDe"
:label="__('Name (DE)')"
required
/>
<flux:error name="nameDe" />
<flux:input
wire:model="slugDe"
:label="__('Slug (DE)')"
:description="__('Wird automatisch aus dem Namen erzeugt, kann überschrieben werden.')"
/>
<flux:error name="slugDe" />
<flux:textarea
wire:model="descriptionDe"
:label="__('Beschreibung (DE)')"
rows="3"
/>
<flux:error name="descriptionDe" />
</div>
</flux:card>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('English translation') }}</flux:heading>
<div class="space-y-4">
<flux:input
wire:model.live.debounce.400ms="nameEn"
:label="__('Name (EN)')"
required
/>
<flux:error name="nameEn" />
<flux:input
wire:model="slugEn"
:label="__('Slug (EN)')"
:description="__('Auto-generated from the name, can be overridden.')"
/>
<flux:error name="slugEn" />
<flux:textarea
wire:model="descriptionEn"
:label="__('Description (EN)')"
rows="3"
/>
<flux:error name="descriptionEn" />
</div>
</flux:card>
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Einstellungen') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $option)
<option value="{{ $option->value }}">{{ $option->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:label>{{ __('Übergeordnete Kategorie') }}</flux:label>
<flux:select wire:model="parentId">
<option value="">{{ __('— Keine —') }}</option>
@foreach($parentOptions as $parent)
<option value="{{ $parent->id }}">
{{ $parent->translations->first()?->name ?? '#'.$parent->id }}
</option>
@endforeach
</flux:select>
<flux:error name="parentId" />
</flux:field>
<flux:checkbox wire:model="isActive" :label="__('Aktiv')" />
</div>
</flux:card>
<flux:button type="submit" variant="primary" class="w-full" icon="check">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
</form>
</div>

View file

@ -0,0 +1,298 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\CategoryTranslation;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class extends Component
{
#[Locked]
public int $id;
#[Validate('required|in:'.Portal::Both->value.','.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
public string $portal = Portal::Both->value;
public ?int $parentId = null;
public bool $isActive = true;
#[Validate('required|string|min:2|max:120')]
public string $nameDe = '';
#[Validate('required|string|min:2|max:120')]
public string $nameEn = '';
#[Validate('required|string|min:1|max:120')]
public string $slugDe = '';
#[Validate('required|string|min:1|max:120')]
public string $slugEn = '';
public string $descriptionDe = '';
public string $descriptionEn = '';
public function mount(int $id): void
{
$this->id = $id;
$category = Category::query()
->with('translations')
->findOrFail($id);
$this->portal = $category->portal->value;
$this->parentId = $category->parent_id;
$this->isActive = (bool) $category->is_active;
$de = $category->translations->firstWhere('locale', 'de');
$en = $category->translations->firstWhere('locale', 'en');
$this->nameDe = $de?->name ?? '';
$this->nameEn = $en?->name ?? '';
$this->slugDe = $de?->slug ?? '';
$this->slugEn = $en?->slug ?? '';
$this->descriptionDe = $de?->description ?? '';
$this->descriptionEn = $en?->description ?? '';
}
public function save(): void
{
$this->validate([
'parentId' => ['nullable', 'integer', Rule::notIn([$this->id]), 'exists:categories,id'],
'descriptionDe' => ['nullable', 'string', 'max:1000'],
'descriptionEn' => ['nullable', 'string', 'max:1000'],
]);
$this->validate();
if ($this->createsParentCycle((int) $this->parentId)) {
$this->addError('parentId', __('Diese Auswahl würde einen Hierarchie-Loop erzeugen.'));
return;
}
DB::transaction(function (): void {
$category = Category::query()->findOrFail($this->id);
$category->update([
'parent_id' => $this->parentId ?: null,
'portal' => $this->portal,
'is_active' => $this->isActive,
]);
$slugDe = CategoryTranslation::uniqueSlug($this->slugDe, 'de', $category->id);
$slugEn = CategoryTranslation::uniqueSlug($this->slugEn, 'en', $category->id);
$category->translations()->updateOrCreate(
['locale' => 'de'],
[
'name' => $this->nameDe,
'slug' => $slugDe,
'description' => $this->descriptionDe ?: null,
],
);
$category->translations()->updateOrCreate(
['locale' => 'en'],
[
'name' => $this->nameEn,
'slug' => $slugEn,
'description' => $this->descriptionEn ?: null,
],
);
$this->slugDe = $slugDe;
$this->slugEn = $slugEn;
});
session()->flash('success', __('Kategorie wurde aktualisiert.'));
}
public function deleteCategory(): void
{
$category = Category::query()->findOrFail($this->id);
if ($category->pressReleases()->withoutGlobalScopes()->exists()) {
session()->flash('error', __('Diese Kategorie ist noch mit Pressemitteilungen verknüpft und kann nicht gelöscht werden.'));
return;
}
if ($category->children()->exists()) {
session()->flash('error', __('Diese Kategorie hat Unterkategorien. Bitte zuerst diese entfernen.'));
return;
}
$category->delete();
session()->flash('success', __('Kategorie wurde gelöscht.'));
$this->redirect(route('admin.categories.index'), navigate: true);
}
public function with(): array
{
return [
'portalOptions' => Portal::cases(),
'parentOptions' => Category::query()
->where('id', '!=', $this->id)
->with(['translations' => fn ($q) => $q->where('locale', 'de')])
->orderBy('id')
->get(['id', 'parent_id', 'portal']),
'releaseCount' => Category::query()->findOrFail($this->id)
->pressReleases()->withoutGlobalScopes()->count(),
'childrenCount' => Category::query()->findOrFail($this->id)->children()->count(),
];
}
private function createsParentCycle(int $candidateId): bool
{
if ($candidateId === 0) {
return false;
}
$current = Category::query()->find($candidateId);
while ($current) {
if ($current->id === $this->id) {
return true;
}
$current = $current->parent_id ? Category::query()->find($current->parent_id) : null;
}
return false;
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<flux:callout color="green" icon="check-circle">{{ session('success') }}</flux:callout>
@endif
@if(session('error'))
<flux:callout color="red" icon="exclamation-triangle">{{ session('error') }}</flux:callout>
@endif
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl">{{ __('Kategorie bearbeiten') }}</flux:heading>
<flux:subheading>ID: {{ $id }} · {{ $releaseCount }} {{ __('PMs') }} · {{ $childrenCount }} {{ __('Unterkategorien') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<form wire:submit="save" class="space-y-6">
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Deutsche Übersetzung') }}</flux:heading>
<div class="space-y-4">
<flux:input wire:model="nameDe" :label="__('Name (DE)')" required />
<flux:error name="nameDe" />
<flux:input wire:model="slugDe" :label="__('Slug (DE)')" required />
<flux:error name="slugDe" />
<flux:textarea wire:model="descriptionDe" :label="__('Beschreibung (DE)')" rows="3" />
<flux:error name="descriptionDe" />
</div>
</flux:card>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('English translation') }}</flux:heading>
<div class="space-y-4">
<flux:input wire:model="nameEn" :label="__('Name (EN)')" required />
<flux:error name="nameEn" />
<flux:input wire:model="slugEn" :label="__('Slug (EN)')" required />
<flux:error name="slugEn" />
<flux:textarea wire:model="descriptionEn" :label="__('Description (EN)')" rows="3" />
<flux:error name="descriptionEn" />
</div>
</flux:card>
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Einstellungen') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $option)
<option value="{{ $option->value }}">{{ $option->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:label>{{ __('Übergeordnete Kategorie') }}</flux:label>
<flux:select wire:model="parentId">
<option value="">{{ __('— Keine —') }}</option>
@foreach($parentOptions as $parent)
<option value="{{ $parent->id }}">
{{ $parent->translations->first()?->name ?? '#'.$parent->id }}
</option>
@endforeach
</flux:select>
<flux:error name="parentId" />
</flux:field>
<flux:checkbox wire:model="isActive" :label="__('Aktiv')" />
</div>
</flux:card>
<flux:button type="submit" variant="primary" class="w-full" icon="check">
{{ __('Änderungen speichern') }}
</flux:button>
<flux:modal.trigger name="confirm-delete-category">
<flux:button type="button" variant="danger" icon="trash" class="w-full">
{{ __('Kategorie löschen') }}
</flux:button>
</flux:modal.trigger>
</div>
</div>
</form>
<flux:modal name="confirm-delete-category" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kategorie wirklich löschen?') }}</flux:heading>
<flux:subheading>
@if($releaseCount > 0)
{{ __('Diese Kategorie hat noch :count Pressemitteilungen und kann nicht gelöscht werden.', ['count' => $releaseCount]) }}
@elseif($childrenCount > 0)
{{ __('Diese Kategorie hat :count Unterkategorien. Bitte erst diese entfernen.', ['count' => $childrenCount]) }}
@else
{{ __('Die Kategorie wird unwiderruflich entfernt.') }}
@endif
</flux:subheading>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deleteCategory" :disabled="$releaseCount > 0 || $childrenCount > 0">
{{ __('Löschen bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,296 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\PressRelease;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\CurrentPortalContext;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Component
{
use WithPagination;
public string $search = '';
public string $sortBy = 'id';
public string $sortDir = 'asc';
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function with(): array
{
$sortable = ['id', 'press_releases_count'];
$sort = in_array($this->sortBy, $sortable) ? $this->sortBy : 'id';
$sortsByCount = $sort === 'press_releases_count';
$categoriesQuery = Category::query()
->select(['id', 'parent_id', 'portal', 'is_active', 'legacy_portal', 'legacy_id', 'created_at', 'updated_at'])
->with(['translations:id,category_id,locale,name,slug,description'])
->when($sortsByCount, fn ($query) => $query->withCount([
'pressReleases',
'pressReleases as presseecho_press_releases_count' => fn ($query) => $query->where('portal', Portal::Presseecho->value),
'pressReleases as businessportal24_press_releases_count' => fn ($query) => $query->where('portal', Portal::Businessportal24->value),
]))
->when(filled($this->search), function ($q): void {
$term = trim($this->search);
$q->whereHas('translations', function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
});
})
->orderBy($sort, $this->sortDir);
$categories = $categoriesQuery->simplePaginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($categories);
}
return [
'categories' => $categories,
'stats' => $this->stats(),
];
}
/**
* @return array{total: int, with_releases: int, total_releases: int, presseecho_releases: int, businessportal24_releases: int}
*/
private function stats(): array
{
$portal = CurrentPortalContext::get()?->value ?? 'all';
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->categoriesStatsKey($portal), AdminPerformanceCache::StatsTtl, function (): array {
$portalReleaseCounts = PressRelease::withoutGlobalScopes()
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN portal = ? THEN 1 ELSE 0 END) as presseecho', [Portal::Presseecho->value])
->selectRaw('SUM(CASE WHEN portal = ? THEN 1 ELSE 0 END) as businessportal24', [Portal::Businessportal24->value])
->first();
return [
'total' => Category::query()->toBase()->count('*'),
'with_releases' => PressRelease::withoutGlobalScopes()
->toBase()
->whereNotNull('category_id')
->distinct()
->count('category_id'),
'total_releases' => (int) ($portalReleaseCounts->total ?? 0),
'presseecho_releases' => (int) ($portalReleaseCounts->presseecho ?? 0),
'businessportal24_releases' => (int) ($portalReleaseCounts->businessportal24 ?? 0),
];
});
}
private function hydrateCounts($categories): void
{
$categoryIds = $categories->getCollection()->pluck('id');
if ($categoryIds->isEmpty()) {
return;
}
$releaseCounts = PressRelease::withoutGlobalScopes()
->whereIn('category_id', $categoryIds)
->selectRaw('category_id, COUNT(*) as aggregate')
->selectRaw('SUM(CASE WHEN portal = ? THEN 1 ELSE 0 END) as presseecho', [Portal::Presseecho->value])
->selectRaw('SUM(CASE WHEN portal = ? THEN 1 ELSE 0 END) as businessportal24', [Portal::Businessportal24->value])
->groupBy('category_id')
->get()
->keyBy('category_id');
$categories->getCollection()->each(function (Category $category) use ($releaseCounts): void {
$counts = $releaseCounts->get($category->id);
$category->setAttribute('press_releases_count', (int) ($counts->aggregate ?? 0));
$category->setAttribute('presseecho_press_releases_count', (int) ($counts->presseecho ?? 0));
$category->setAttribute('businessportal24_press_releases_count', (int) ($counts->businessportal24 ?? 0));
});
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-6">
{{-- Statistiken --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Kategorien') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</div>
<flux:icon.folder class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Mit PMs') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['with_releases'] }}</flux:text>
</div>
<flux:icon.document-text class="size-8 text-green-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('PMs gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total_releases'] }}</flux:text>
</div>
<flux:icon.newspaper class="size-8 text-purple-500" />
</div>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Portale') }}</flux:text>
<div class="mt-2 space-y-1 text-sm">
<div class="flex items-center justify-between gap-3">
<span>{{ __('Presseecho') }}</span>
<span class="font-semibold">{{ number_format($stats['presseecho_releases']) }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span>{{ __('Businessportal24') }}</span>
<span class="font-semibold">{{ number_format($stats['businessportal24_releases']) }}</span>
</div>
</div>
</flux:card>
</div>
{{-- Filter + Aktion --}}
<flux:card>
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-[260px] flex-1">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Kategorie suchen (Name, Slug)…') }}"
icon="magnifying-glass"
/>
</div>
<flux:button
variant="primary"
icon="plus"
:href="route('admin.categories.create')"
wire:navigate
>
{{ __('Kategorie anlegen') }}
</flux:button>
</div>
</flux:card>
{{-- Karten --}}
{{-- Sortier-Buttons --}}
<flux:card>
<div class="flex items-center gap-2 text-sm">
<span class="text-zinc-500">{{ __('Sortierung:') }}</span>
<flux:button size="sm" variant="{{ $sortBy === 'id' ? 'primary' : 'ghost' }}" wire:click="sort('id')">
{{ __('Standard') }} @if($sortBy==='id') {{ $sortDir === 'asc' ? '↑' : '↓' }} @endif
</flux:button>
<flux:button size="sm" variant="{{ $sortBy === 'press_releases_count' ? 'primary' : 'ghost' }}" wire:click="sort('press_releases_count')">
{{ __('PMs') }} @if($sortBy==='press_releases_count') {{ $sortDir === 'asc' ? '↑' : '↓' }} @endif
</flux:button>
</div>
</flux:card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@forelse($categories as $category)
@php
$de = $category->translations->firstWhere('locale', 'de');
$en = $category->translations->firstWhere('locale', 'en');
@endphp
<flux:card class="group relative h-full transition hover:border-blue-300 hover:bg-blue-50/40 dark:hover:border-blue-700 dark:hover:bg-blue-950/20">
<a href="{{ route('admin.press-releases.index', ['category' => $category->id]) }}" wire:navigate class="absolute inset-0 z-0" aria-hidden="true"></a>
<div class="relative z-10 space-y-4">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<flux:heading size="lg" class="truncate">{{ $de?->name ?? '' }}</flux:heading>
<flux:text class="truncate text-sm text-zinc-500">{{ $en?->name ?? '' }}</flux:text>
</div>
<div class="flex items-center gap-2">
<flux:badge color="{{ $category->is_active ? 'green' : 'zinc' }}" size="sm">
{{ $category->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<flux:button
size="xs"
variant="ghost"
icon="pencil"
:href="route('admin.categories.edit', $category->id)"
wire:navigate
:title="__('Bearbeiten')"
/>
</div>
</div>
@if($de?->description)
<flux:text class="line-clamp-2 text-sm text-zinc-600 dark:text-zinc-400">
{{ $de->description }}
</flux:text>
@endif
<div class="grid grid-cols-2 gap-2 border-t border-zinc-200 pt-3 text-xs dark:border-zinc-700">
<div class="rounded-md bg-zinc-50 px-2 py-1 dark:bg-zinc-800">
<div class="text-zinc-500">{{ __('Presseecho') }}</div>
<div class="font-semibold">{{ number_format($category->presseecho_press_releases_count) }}</div>
</div>
<div class="rounded-md bg-zinc-50 px-2 py-1 dark:bg-zinc-800">
<div class="text-zinc-500">{{ __('Businessportal24') }}</div>
<div class="font-semibold">{{ number_format($category->businessportal24_press_releases_count) }}</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-zinc-200 pt-3 dark:border-zinc-700">
<div class="flex items-center gap-1.5 text-sm text-zinc-500">
<flux:icon.newspaper class="size-4" />
{{ $category->press_releases_count }} {{ __('PMs') }}
</div>
<flux:badge color="zinc" size="sm">/{{ $de?->slug ?? $category->id }}</flux:badge>
</div>
</div>
</flux:card>
@empty
<div class="col-span-full">
<flux:card>
<div class="flex flex-col items-center justify-center py-12">
<flux:icon.folder class="size-12 text-zinc-400 dark:text-zinc-600" />
<flux:text class="mt-4 text-zinc-500">{{ __('Keine Kategorien gefunden.') }}</flux:text>
</div>
</flux:card>
</div>
@endforelse
</div>
{{ $categories->links() }}
</div>

View file

@ -0,0 +1,281 @@
<?php
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Component
{
use WithFileUploads;
public string $portal = 'both';
public string $type = 'company';
#[Validate('required|min:3|max:255')]
public string $company_name = '';
#[Validate('nullable|max:500')]
public string $description = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('nullable|max:50')]
public string $phone = '';
#[Validate('nullable|url')]
public string $website = '';
#[Validate('nullable|max:255')]
public string $street = '';
#[Validate('nullable|max:20')]
public string $zip = '';
#[Validate('nullable|max:255')]
public string $city = '';
#[Validate('nullable|max:255')]
public string $state = '';
#[Validate('required|max:2')]
public string $country = 'DE';
#[Validate('nullable|image|max:1024')]
public $logo;
#[Validate('nullable|max:255')]
public ?string $tax_id = null;
#[Validate('nullable|max:255')]
public ?string $registration_number = null;
public bool $is_verified = false;
public bool $is_active = true;
public function save(): void
{
$this->validate();
$slug = (new Company)->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);
$logoPath = $this->logo
? $this->logo->store('company-logos', 'public')
: null;
Company::query()->create([
'portal' => $this->portal,
'type' => $this->type,
'name' => $this->company_name,
'slug' => $slug,
'address' => $this->composeAddress(),
'country_code' => strtoupper($this->country),
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'logo_path' => $logoPath,
'is_active' => $this->is_active,
]);
session()->flash('success', 'Firma erfolgreich erstellt.');
$this->redirect(route('admin.companies.index'), navigate: true);
}
public function with(): array
{
return [
'countries' => collect([
['code' => 'DE', 'name' => 'Deutschland'],
['code' => 'AT', 'name' => 'Österreich'],
['code' => 'CH', 'name' => 'Schweiz'],
['code' => 'FR', 'name' => 'Frankreich'],
['code' => 'GB', 'name' => 'Großbritannien'],
['code' => 'US', 'name' => 'USA'],
]),
'portalOptions' => Portal::cases(),
'typeOptions' => CompanyType::cases(),
];
}
protected function composeAddress(): ?string
{
$lineOne = trim($this->street);
$lineTwo = trim(trim($this->zip).' '.trim($this->city));
$lineThree = trim($this->state);
$parts = array_filter([$lineOne, $lineTwo, $lineThree], fn ($value) => $value !== '');
return $parts !== [] ? implode(', ', $parts) : null;
}
}; ?>
<form wire:submit="save" class="space-y-6">
{{-- Basisinformationen --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basisinformationen') }}</flux:heading>
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Typ') }}</flux:label>
<flux:select wire:model="type">
@foreach($typeOptions as $typeOption)
<option value="{{ $typeOption->value }}">{{ $typeOption->label() }}</option>
@endforeach
</flux:select>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Firmenname') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="company_name" placeholder="{{ __('Vollständiger Firmenname...') }}" />
<flux:error name="company_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="4" placeholder="{{ __('Kurze Beschreibung der Firma (optional)...') }}" />
<flux:error name="description" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('E-Mail') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="email" type="email" placeholder="{{ __('kontakt@firma.de') }}" icon="envelope" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="phone" type="tel" placeholder="{{ __('+49 123 456789') }}" icon="phone" />
<flux:error name="phone" />
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Website') }}</flux:label>
<flux:input wire:model="website" type="url" placeholder="{{ __('https://www.firma.de') }}" icon="globe-alt" />
<flux:error name="website" />
</flux:field>
</div>
</flux:card>
{{-- Adresse --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Adresse') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Straße & Hausnummer') }}</flux:label>
<flux:input wire:model="street" placeholder="{{ __('Musterstraße 123') }}" />
<flux:error name="street" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-3">
<flux:field>
<flux:label>{{ __('PLZ') }}</flux:label>
<flux:input wire:model="zip" placeholder="{{ __('12345') }}" />
<flux:error name="zip" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Stadt') }}</flux:label>
<flux:input wire:model="city" placeholder="{{ __('Berlin') }}" />
<flux:error name="city" />
</flux:field>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Bundesland / Region') }}</flux:label>
<flux:input wire:model="state" placeholder="{{ __('Bayern') }}" />
<flux:error name="state" />
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="country">
@foreach($countries as $country)
<option value="{{ $country['code'] }}">{{ $country['name'] }}</option>
@endforeach
</flux:select>
<flux:error name="country" />
</flux:field>
</div>
</div>
</flux:card>
{{-- Rechtliche Daten --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rechtliche Daten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Steuernummer / USt-IdNr.') }}</flux:label>
<flux:input wire:model="tax_id" placeholder="{{ __('DE123456789') }}" />
<flux:error name="tax_id" />
</flux:field>
<flux:field>
<flux:label>{{ __('Handelsregisternummer') }}</flux:label>
<flux:input wire:model="registration_number" placeholder="{{ __('HRB 12345') }}" />
<flux:error name="registration_number" />
</flux:field>
</div>
</flux:card>
{{-- Logo & Status --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Logo & Status') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Firmenlogo') }}</flux:label>
<flux:input type="file" wire:model="logo" accept="image/*" />
<flux:description>{{ __('Maximal 1 MB. Empfohlen: quadratisch, min. 400x400px') }}</flux:description>
<flux:error name="logo" />
@if ($logo)
<div class="mt-4">
<flux:text class="text-sm text-zinc-500 mb-2">{{ __('Vorschau:') }}</flux:text>
<img src="{{ $logo->temporaryUrl() }}" width="128" height="128" class="h-32 max-h-32 w-32 max-w-32 rounded-lg border border-zinc-200 object-contain dark:border-zinc-700">
</div>
@endif
</flux:field>
<div class="flex gap-6">
<flux:checkbox wire:model="is_verified" label="{{ __('Firma ist verifiziert') }}" />
<flux:checkbox wire:model="is_active" label="{{ __('Firma ist aktiv') }}" />
</div>
</div>
</flux:card>
{{-- Aktionen --}}
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Firma erstellen') }}
</flux:button>
</div>
</flux:card>
</form>

View file

@ -0,0 +1,412 @@
<?php
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use App\Services\Image\ImageService;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends Component
{
use WithFileUploads;
public int $companyId;
public string $portal = 'both';
public string $type = 'company';
#[Validate('required|min:3|max:255')]
public string $company_name = '';
#[Validate('nullable|max:500')]
public string $description = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('nullable|max:50')]
public string $phone = '';
#[Validate('nullable|url')]
public string $website = '';
#[Validate('nullable|max:255')]
public string $street = '';
#[Validate('nullable|max:20')]
public string $zip = '';
#[Validate('nullable|max:255')]
public string $city = '';
#[Validate('nullable|max:255')]
public string $state = '';
#[Validate('required|max:2')]
public string $country = 'DE';
#[Validate('nullable|image|max:4096')]
public $logo;
public bool $remove_logo = false;
public ?string $current_logo_url = null;
#[Validate('nullable|max:255')]
public ?string $tax_id = null;
#[Validate('nullable|max:255')]
public ?string $registration_number = null;
public bool $is_verified = false;
public bool $is_active = true;
public function mount(int $id): void
{
$this->companyId = $id;
$company = Company::query()->find($id);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$this->portal = $company->portal?->value ?? Portal::Both->value;
$this->type = $company->type?->value ?? CompanyType::Company->value;
$this->company_name = $company->name;
$this->description = '';
$this->email = $company->email ?? '';
$this->phone = $company->phone ?? '';
$this->website = $company->website ?? '';
$this->street = $company->address ?? '';
$this->zip = '';
$this->city = '';
$this->state = '';
$this->country = $company->country_code ?? 'DE';
$this->is_verified = false;
$this->is_active = (bool) $company->is_active;
$this->current_logo_url = $company->logoUrl();
}
public function update(ImageService $imageService): void
{
$this->validate();
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$slug = $company->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);
$logoPath = $company->logo_path;
$logoVariants = $company->logo_variants;
if ($this->remove_logo) {
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
$logoPath = null;
$logoVariants = null;
}
if ($this->logo) {
$imageService->deleteCompanyLogo($logoPath, $logoVariants);
$stored = $imageService->storeCompanyLogo(
$this->logo,
$this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal,
$company->id,
);
$logoPath = $stored['path'];
$logoVariants = $stored['variants'];
}
$company->update([
'portal' => $this->portal,
'type' => $this->type,
'name' => $this->company_name,
'slug' => $slug,
'address' => $this->composeAddress(),
'country_code' => strtoupper($this->country),
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'logo_path' => $logoPath,
'logo_variants' => $logoVariants,
'is_active' => $this->is_active,
]);
session()->flash('success', 'Firma erfolgreich aktualisiert.');
$this->redirect(route('admin.companies.index'), navigate: true);
}
public function with(): array
{
return [
'countries' => collect([
['code' => 'DE', 'name' => 'Deutschland'],
['code' => 'AT', 'name' => 'Österreich'],
['code' => 'CH', 'name' => 'Schweiz'],
['code' => 'FR', 'name' => 'Frankreich'],
['code' => 'GB', 'name' => 'Großbritannien'],
['code' => 'US', 'name' => 'USA'],
]),
'portalOptions' => Portal::cases(),
'typeOptions' => CompanyType::cases(),
];
}
public function deleteCompany(): void
{
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
$company->delete();
session()->flash('success', __('Firma wurde erfolgreich gelöscht.'));
$this->redirect(route('admin.companies.index'), navigate: true);
}
protected function composeAddress(): ?string
{
$lineOne = trim($this->street);
$lineTwo = trim(trim($this->zip).' '.trim($this->city));
$lineThree = trim($this->state);
$parts = array_filter([$lineOne, $lineTwo, $lineThree], fn ($value) => $value !== '');
return $parts !== [] ? implode(', ', $parts) : null;
}
}; ?>
<div class="space-y-6">
<form wire:submit="update" class="space-y-6">
{{-- Basisinformationen --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basisinformationen') }}</flux:heading>
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Typ') }}</flux:label>
<flux:select wire:model="type">
@foreach($typeOptions as $typeOption)
<option value="{{ $typeOption->value }}">{{ $typeOption->label() }}</option>
@endforeach
</flux:select>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Firmenname') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="company_name" placeholder="{{ __('Vollständiger Firmenname...') }}" />
<flux:error name="company_name" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="4" placeholder="{{ __('Kurze Beschreibung der Firma (optional)...') }}" />
<flux:error name="description" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('E-Mail') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="email" type="email" placeholder="{{ __('kontakt@firma.de') }}" icon="envelope" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="phone" type="tel" placeholder="{{ __('+49 123 456789') }}" icon="phone" />
<flux:error name="phone" />
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Website') }}</flux:label>
<flux:input wire:model="website" type="url" placeholder="{{ __('https://www.firma.de') }}" icon="globe-alt" />
<flux:error name="website" />
</flux:field>
</div>
</flux:card>
{{-- Adresse --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Adresse') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Straße & Hausnummer') }}</flux:label>
<flux:input wire:model="street" placeholder="{{ __('Musterstraße 123') }}" />
<flux:error name="street" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-3">
<flux:field>
<flux:label>{{ __('PLZ') }}</flux:label>
<flux:input wire:model="zip" placeholder="{{ __('12345') }}" />
<flux:error name="zip" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Stadt') }}</flux:label>
<flux:input wire:model="city" placeholder="{{ __('Berlin') }}" />
<flux:error name="city" />
</flux:field>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Bundesland / Region') }}</flux:label>
<flux:input wire:model="state" placeholder="{{ __('Bayern') }}" />
<flux:error name="state" />
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="country">
@foreach($countries as $country)
<option value="{{ $country['code'] }}">{{ $country['name'] }}</option>
@endforeach
</flux:select>
<flux:error name="country" />
</flux:field>
</div>
</div>
</flux:card>
{{-- Rechtliche Daten --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rechtliche Daten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Steuernummer / USt-IdNr.') }}</flux:label>
<flux:input wire:model="tax_id" placeholder="{{ __('DE123456789') }}" />
<flux:error name="tax_id" />
</flux:field>
<flux:field>
<flux:label>{{ __('Handelsregisternummer') }}</flux:label>
<flux:input wire:model="registration_number" placeholder="{{ __('HRB 12345') }}" />
<flux:error name="registration_number" />
</flux:field>
</div>
</flux:card>
{{-- Logo & Status --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Logo & Status') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Firmenlogo') }}</flux:label>
<flux:input type="file" wire:model="logo" accept="image/jpeg,image/png,image/webp,image/gif" />
<flux:description>{{ __('Maximal 4 MB. Varianten (sq/wide) werden automatisch generiert.') }}</flux:description>
<flux:error name="logo" />
@if ($logo)
<div class="mt-4">
<flux:text class="text-sm text-zinc-500 mb-2">{{ __('Neues Logo (Vorschau):') }}</flux:text>
<img src="{{ $logo->temporaryUrl() }}" width="128" height="128" class="h-32 max-h-32 w-32 max-w-32 rounded-lg border border-zinc-200 object-contain dark:border-zinc-700">
</div>
@elseif($current_logo_url && ! $remove_logo)
<div class="mt-4 flex items-center gap-3">
<div>
<flux:text class="text-sm text-zinc-500 mb-2">{{ __('Aktuelles Logo:') }}</flux:text>
<img src="{{ $current_logo_url }}" width="128" height="128" class="h-32 max-h-32 w-32 max-w-32 rounded-lg border border-zinc-200 object-contain dark:border-zinc-700">
</div>
<flux:button type="button" size="sm" variant="ghost" wire:click="$set('remove_logo', true)">
{{ __('Logo entfernen') }}
</flux:button>
</div>
@elseif($remove_logo)
<flux:callout color="amber" icon="exclamation-triangle" class="mt-4">
{{ __('Logo wird beim Speichern entfernt.') }}
<flux:button type="button" size="sm" variant="ghost" wire:click="$set('remove_logo', false)">
{{ __('Rückgängig') }}
</flux:button>
</flux:callout>
@endif
</flux:field>
<div class="flex gap-6">
<flux:checkbox wire:model="is_verified" label="{{ __('Firma ist verifiziert') }}" />
<flux:checkbox wire:model="is_active" label="{{ __('Firma ist aktiv') }}" />
</div>
</div>
</flux:card>
{{-- Aktionen --}}
<flux:card>
<div class="flex justify-between">
<flux:modal.trigger name="confirm-company-deletion">
<flux:button
variant="danger"
icon="trash"
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-company-deletion')"
>
{{ __('Löschen') }}
</flux:button>
</flux:modal.trigger>
<div class="flex gap-3">
<flux:button variant="ghost" href="{{ route('admin.companies.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Änderungen speichern') }}
</flux:button>
</div>
</div>
</flux:card>
</form>
<flux:modal name="confirm-company-deletion" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Firma wirklich löschen?') }}</flux:heading>
<flux:subheading>
{{ __('Diese Aktion kann nicht direkt rückgängig gemacht werden. Die Firma wird archiviert (Soft Delete) und aus den Standardlisten entfernt.') }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deleteCompany">
{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,573 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\CurrentPortalContext;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $activeFilter = 'all';
#[Url(as: 'portal', except: 'all')]
public string $portalFilter = 'all';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
public string $userLookup = '';
#[Url(as: 'contact', except: 'all')]
public string $contactFilter = 'all';
public string $contactLookup = '';
#[Url(as: 'data', except: 'all')]
public string $qualityFilter = 'all';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
public function with(): array
{
$sortable = ['name', 'email', 'is_active', 'press_releases_count', 'contacts_count', 'created_at'];
$sort = in_array($this->sortBy, $sortable) ? $this->sortBy : 'name';
$sortsByCount = in_array($sort, ['press_releases_count', 'contacts_count'], true);
$companiesQuery = Company::query()
->when($sortsByCount, fn ($query) => $query->withCount(['pressReleases', 'contacts']))
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$query->where(function ($searchQuery): void {
$searchQuery->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%');
});
})
->when($this->activeFilter !== 'all', function ($query): void {
$query->where('is_active', $this->activeFilter === 'active');
})
->when($this->portalFilter !== 'all', function ($query): void {
$query->where('portal', $this->portalFilter);
})
->when($this->userFilter !== 'all', function ($query): void {
$query->where(function ($userScopedQuery): void {
$userScopedQuery
->where('owner_user_id', (int) $this->userFilter)
->orWhereHas('users', fn ($userQuery) => $userQuery->where('users.id', (int) $this->userFilter));
});
})
->when($this->contactFilter !== 'all', function ($query): void {
$query->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter));
})
->when($this->qualityFilter !== 'all', function ($query): void {
match ($this->qualityFilter) {
'with_press_releases' => $query->whereHas('pressReleases'),
'without_press_releases' => $query->whereDoesntHave('pressReleases'),
'with_contacts' => $query->whereHas('contacts'),
'without_contacts' => $query->whereDoesntHave('contacts'),
default => null,
};
})
->orderBy($sort, $this->sortDir);
$companies = $companiesQuery->simplePaginate(50);
if (! $sortsByCount) {
$this->hydrateCounts($companies);
}
return [
'companies' => $companies,
'stats' => $this->stats(),
'portalOptions' => Portal::cases(),
'userLookupResults' => $this->userLookupResults(),
'contactLookupResults' => $this->contactLookupResults(),
];
}
/**
* @return array{total: int, active: int, inactive: int}
*/
private function stats(): array
{
$portal = CurrentPortalContext::get()?->value ?? 'all';
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->companiesStatsKey($portal), AdminPerformanceCache::StatsTtl, function (): array {
$stats = Company::query()
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN is_active = ? THEN 1 ELSE 0 END) as active', [true])
->selectRaw('SUM(CASE WHEN is_active = ? THEN 1 ELSE 0 END) as inactive', [false])
->first();
return [
'total' => (int) ($stats->total ?? 0),
'active' => (int) ($stats->active ?? 0),
'inactive' => (int) ($stats->inactive ?? 0),
];
});
}
private function hydrateCounts($companies): void
{
$companyIds = $companies->getCollection()->pluck('id');
if ($companyIds->isEmpty()) {
return;
}
$pressReleaseCounts = PressRelease::query()
->whereIn('company_id', $companyIds)
->selectRaw('company_id, COUNT(*) as aggregate')
->groupBy('company_id')
->pluck('aggregate', 'company_id');
$contactCounts = Contact::query()
->whereIn('company_id', $companyIds)
->selectRaw('company_id, COUNT(*) as aggregate')
->groupBy('company_id')
->pluck('aggregate', 'company_id');
$companies->getCollection()->each(function (Company $company) use ($pressReleaseCounts, $contactCounts): void {
$company->setAttribute('press_releases_count', (int) $pressReleaseCounts->get($company->id, 0));
$company->setAttribute('contacts_count', (int) $contactCounts->get($company->id, 0));
});
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedActiveFilter(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
if (blank($this->userFilter)) {
$this->userFilter = 'all';
}
$this->userLookup = '';
$this->resetPage();
}
public function updatedContactFilter(): void
{
if (blank($this->contactFilter)) {
$this->contactFilter = 'all';
}
$this->contactLookup = '';
$this->resetPage();
}
public function updatedQualityFilter(): void
{
$this->resetPage();
}
public function clearUserSearch(): void
{
$this->userFilter = 'all';
$this->userLookup = '';
$this->resetPage();
}
public function clearContactSearch(): void
{
$this->contactFilter = 'all';
$this->contactLookup = '';
$this->resetPage();
}
private function portalBadgeColor(?Portal $portal): string
{
return match ($portal) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
private function userLookupResults()
{
$term = trim($this->userLookup);
if ($term === '' && $this->userFilter === 'all') {
return collect();
}
return User::query()
->select(['id', 'name', 'email'])
->where(function ($query) use ($term): void {
if ($this->userFilter !== 'all') {
$query->where('id', (int) $this->userFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('name')
->limit(20)
->get();
}
private function contactLookupResults()
{
$term = trim($this->contactLookup);
if ($term === '' && $this->contactFilter === 'all') {
return collect();
}
return Contact::withoutGlobalScopes()
->select(['id', 'company_id', 'first_name', 'last_name', 'email'])
->with('company:id,name')
->where(function ($query) use ($term): void {
if ($this->contactFilter !== 'all') {
$query->where('id', (int) $this->contactFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('first_name', 'like', '%'.$term.'%')
->orWhere('last_name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('last_name')
->orderBy('first_name')
->limit(20)
->get();
}
}; ?>
<div class="space-y-6">
{{-- Statistiken --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</div>
<flux:icon.building-office class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Aktiv') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['active'] }}</flux:text>
</div>
<flux:icon.check-circle class="size-8 text-green-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Inaktiv') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['inactive'] }}</flux:text>
</div>
<flux:icon.x-circle class="size-8 text-red-500" />
</div>
</flux:card>
</div>
{{-- Filter & Actions --}}
<flux:card>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-7">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Suchen...') }}"
icon="magnifying-glass" class="xl:col-span-2" />
<flux:select wire:model.live="activeFilter" class="w-full">
<option value="all">{{ __('Alle') }}</option>
<option value="active">{{ __('Aktiv') }}</option>
<option value="inactive">{{ __('Inaktiv') }}</option>
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="qualityFilter" class="w-full">
<option value="all">{{ __('Alle Datenstände') }}</option>
<option value="with_press_releases">{{ __('Mit Pressemitteilungen') }}</option>
<option value="without_press_releases">{{ __('Ohne Pressemitteilungen') }}</option>
<option value="with_contacts">{{ __('Mit Kontakten') }}</option>
<option value="without_contacts">{{ __('Ohne Kontakte') }}</option>
</flux:select>
<div class="flex gap-2">
<flux:select
wire:model.live="userFilter"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Alle User') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="userLookup" placeholder="{{ __('User suchen…') }}" />
</x-slot>
@foreach($userLookupResults as $userOption)
<flux:select.option :value="$userOption->id" wire:key="company-user-{{ $userOption->id }}">
{{ $userOption->name }}
<span class="ml-1 text-zinc-400">· {{ $userOption->email }}</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($userLookup)) ? __('Usernamen oder E-Mail eingeben…') : __('Kein User gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearUserSearch"
title="{{ __('Usersuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="contactFilter"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Alle Kontakte') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="contactLookup" placeholder="{{ __('Kontakt suchen…') }}" />
</x-slot>
@foreach($contactLookupResults as $contactOption)
@php($contactName = trim(($contactOption->first_name ?? '').' '.($contactOption->last_name ?? '')) ?: __('Kontakt ohne Name'))
<flux:select.option :value="$contactOption->id" wire:key="company-contact-{{ $contactOption->id }}">
{{ $contactName }}
<span class="ml-1 text-zinc-400">
@if($contactOption->email)· {{ $contactOption->email }} @endif
· {{ $contactOption->company?->name ?? __('Unbekannte Firma') }}
</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($contactLookup)) ? __('Kontaktname oder E-Mail eingeben…') : __('Kein Kontakt gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearContactSearch"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
</div>
<div class="flex justify-end">
<flux:button icon="plus" href="{{ route('admin.companies.create') }}" wire:navigate>
{{ __('Neue Firma') }}
</flux:button>
</div>
</div>
</flux:card>
{{-- Tabelle --}}
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir"
wire:click="sort('name')">
{{ __('Firma') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'email'" :direction="$sortDir"
wire:click="sort('email')">{{ __('Kontakt') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'is_active'" :direction="$sortDir"
wire:click="sort('is_active')">{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'press_releases_count'" :direction="$sortDir"
wire:click="sort('press_releases_count')">{{ __('PMs') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'contacts_count'" :direction="$sortDir"
wire:click="sort('contacts_count')">{{ __('Kontakte') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
wire:click="sort('created_at')">{{ __('Hinzugefügt') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($companies as $company)
<flux:table.row :key="$company->id">
@php($logoUrl = $company->logoUrl())
<flux:table.cell>
<div class="flex gap-2">
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.companies.edit', $company->id) }}" wire:navigate />
<flux:button size="sm" variant="ghost" icon="eye"
href="{{ route('admin.companies.show', $company->id) }}" wire:navigate />
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-3">
@if($logoUrl)
<img src="{{ $logoUrl }}" width="36" height="36" class="h-9 max-h-9 w-9 max-w-9 rounded-md border border-zinc-200 object-contain dark:border-zinc-700" alt="{{ $company->name }}">
@else
<div class="flex h-9 w-9 items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
<flux:icon.building-office class="size-5 text-zinc-400" />
</div>
@endif
<flux:text weight="semibold"> {{ \Illuminate\Support\Str::limit($company->name, 60) }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-1">
<flux:text class="text-sm">{{ $company->email ?: __('Keine E-Mail') }}</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $company->phone ?: __('Kein Telefon') }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
@if ($company->is_active)
<flux:badge color="green" size="sm" icon="check">{{ __('Aktiv') }}
</flux:badge>
@else
<flux:badge color="red" size="sm" icon="x-mark">{{ __('Inaktiv') }}
</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ $this->portalBadgeColor($company->portal) }}" size="sm">
{{ $company->portal?->label() ?? __('Unbekannt') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if ($company->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}"
wire:navigate
>
{{ $company->press_releases_count }} {{ __('PMs') }}
</flux:button>
@else
<flux:badge color="zinc" size="sm">0</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($company->contacts_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.contacts.index', ['company' => $company->id]) }}"
wire:navigate
>
{{ $company->contacts_count }} {{ __('Kontakte') }}
</flux:button>
@else
<flux:badge color="zinc" size="sm">0</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">
{{ $company->created_at?->format('d.m.Y H:i') ?? '' }}
</flux:text>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center py-12">
<flux:icon.building-office class="size-12 text-zinc-400 dark:text-zinc-600" />
<flux:text class="mt-4 text-zinc-500">{{ __('Keine Firmen gefunden') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
{{ $companies->links() }}
</div>
</flux:card>
</div>

View file

@ -0,0 +1,400 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends Component
{
public int $id;
public string $activeTab = 'overview';
public string $contactSearch = '';
public string $contactLookup = '';
public ?int $selectedExistingContactId = null;
public function updatedSelectedExistingContactId(): void
{
if ($this->selectedExistingContactId) {
$this->attachExistingContact();
}
}
public function mount(int $id): void
{
$this->id = $id;
$companyExists = Company::query()->whereKey($id)->exists();
if (! $companyExists) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
$this->redirect(route('admin.companies.index'), navigate: true);
return;
}
}
public function setTab(string $tab): void
{
if (in_array($tab, ['overview', 'contacts'], true)) {
$this->activeTab = $tab;
}
}
public function updatedContactLookup(): void
{
$this->selectedExistingContactId = null;
}
public function attachExistingContact(): void
{
if (! $this->selectedExistingContactId) {
return;
}
$company = Company::query()->find($this->id);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));
return;
}
$contact = Contact::query()->find($this->selectedExistingContactId);
if (! $contact) {
session()->flash('error', __('Der ausgewählte Kontakt wurde nicht gefunden.'));
return;
}
if ($contact->company_id === $company->id) {
session()->flash('info', __('Der Kontakt ist bereits dieser Firma zugeordnet.'));
return;
}
$contact->update(['company_id' => $company->id]);
$this->contactLookup = '';
$this->selectedExistingContactId = null;
session()->flash('success', __('Kontakt wurde der Firma zugeordnet.'));
}
public function with(): array
{
$company = Company::query()
->with([
'pressReleases' => fn ($query) => $query->latest('id')->limit(10),
'users:id,name,email',
])
->withCount(['pressReleases', 'contacts', 'users'])
->find($this->id);
if (! $company) {
return [
'company' => Company::make([
'id' => $this->id,
'name' => __('Unbekannte Firma'),
'is_active' => false,
]),
'filteredContacts' => collect(),
'filteredContactsTotal' => 0,
'contactLookupResults' => collect(),
'recentPressReleases' => collect(),
];
}
$filteredContacts = collect();
$filteredContactsTotal = 0;
if ($this->activeTab === 'contacts') {
$contactsQuery = Contact::query()
->where('company_id', $company->id)
->when(filled($this->contactSearch), function ($query): void {
$search = trim($this->contactSearch);
if ($this->supportsFullTextSearch($search)) {
$query->whereFullText(['first_name', 'last_name', 'email', 'responsibility'], $search);
return;
}
$query->where(function ($query) use ($search): void {
$query->where('first_name', 'like', '%'.$search.'%')
->orWhere('last_name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('responsibility', 'like', '%'.$search.'%');
});
});
$filteredContactsTotal = (clone $contactsQuery)->count();
$filteredContacts = $contactsQuery
->orderBy('last_name')
->orderBy('first_name')
->limit(100)
->get(['id', 'company_id', 'portal', 'first_name', 'last_name', 'responsibility', 'email']);
}
$contactLookupResults = collect();
$lookupTerm = trim($this->contactLookup);
if ($this->activeTab === 'contacts' && mb_strlen($lookupTerm) >= 1) {
$contactLookupResults = Contact::withoutGlobalScopes()
->with('company:id,name')
->where('company_id', '!=', $company->id)
->where(function ($query) use ($lookupTerm): void {
if ($this->supportsFullTextSearch($lookupTerm)) {
$query->whereFullText(['first_name', 'last_name', 'email', 'responsibility'], $lookupTerm);
return;
}
$query
->where('first_name', 'like', '%'.$lookupTerm.'%')
->orWhere('last_name', 'like', '%'.$lookupTerm.'%')
->orWhere('email', 'like', '%'.$lookupTerm.'%');
})
->orderBy('last_name')
->orderBy('first_name')
->limit(50)
->get(['id', 'company_id', 'first_name', 'last_name', 'email']);
}
return [
'company' => $company,
'filteredContacts' => $filteredContacts,
'filteredContactsTotal' => $filteredContactsTotal,
'contactLookupResults' => $contactLookupResults,
'recentPressReleases' => $company->pressReleases,
];
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
private function portalBadgeColor(?Portal $portal): string
{
return match ($portal) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex gap-4">
@php($logoUrl = $company->logoUrl())
@if($logoUrl)
<img src="{{ $logoUrl }}" width="80" height="80" class="h-20 max-h-20 w-20 max-w-20 rounded-lg border border-zinc-200 object-contain dark:border-zinc-700" alt="{{ $company->name }}">
@else
<div class="flex h-20 w-20 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
<flux:icon.building-office class="size-10 text-zinc-400" />
</div>
@endif
<div>
<flux:heading size="xl" class="mb-2">{{ $company->name }}</flux:heading>
<div class="flex flex-wrap gap-2">
@if($company->is_active)
<flux:badge color="green" size="sm">{{ __('Aktiv') }}</flux:badge>
@else
<flux:badge color="red" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endif
<flux:badge color="{{ $this->portalBadgeColor($company->portal) }}" size="sm">{{ $company->portal?->label() ?? __('Unbekannt') }}</flux:badge>
<flux:text class="ml-2 text-sm text-zinc-500">ID: {{ $company->id }}</flux:text>
</div>
</div>
</div>
<div class="flex gap-2">
<flux:button icon="pencil" href="{{ route('admin.companies.edit', $company->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
<flux:button variant="ghost" icon="user-plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
{{ __('Kontakt hinzufügen') }}
</flux:button>
@endif
</div>
</div>
</flux:card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<flux:card>
<flux:text class="text-sm text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $company->press_releases_count }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500">{{ __('Kontakte') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $company->contacts_count }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500">{{ __('Verknüpfte Benutzer') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $company->users_count }}</flux:text>
</flux:card>
</div>
<flux:card>
<div class="flex gap-2">
<flux:button
:variant="$activeTab === 'overview' ? 'primary' : 'ghost'"
wire:click="setTab('overview')"
>
{{ __('Überblick') }}
</flux:button>
<flux:button
:variant="$activeTab === 'contacts' ? 'primary' : 'ghost'"
wire:click="setTab('contacts')"
>
{{ __('Kontakte') }}
</flux:button>
</div>
</flux:card>
@if($activeTab === 'overview')
<div class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Kontaktinformationen') }}</flux:heading>
<div class="space-y-2">
<flux:text>{{ $company->email ?: __('Keine E-Mail hinterlegt') }}</flux:text>
<flux:text>{{ $company->phone ?: __('Kein Telefon hinterlegt') }}</flux:text>
<flux:text>{{ $company->website ?: __('Keine Website hinterlegt') }}</flux:text>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Adresse') }}</flux:heading>
<div class="space-y-2">
<flux:text>{{ $company->address ?: __('Keine Adresse hinterlegt') }}</flux:text>
<flux:text>{{ $company->country_code ?: __('Kein Land hinterlegt') }}</flux:text>
</div>
</flux:card>
<flux:card class="lg:col-span-2">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ __('Aktuelle Pressemitteilungen') }}</flux:heading>
<flux:button size="sm" variant="ghost" href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
<div class="space-y-2">
@forelse($recentPressReleases as $pressRelease)
<a href="{{ route('admin.press-releases.show', $pressRelease->id) }}" wire:navigate class="block rounded-lg p-3 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900">
<flux:text weight="medium">{{ $pressRelease->title ?? __('Ohne Titel') }}</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $pressRelease->created_at?->format('d.m.Y') ?? '-' }}</flux:text>
</a>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Pressemitteilungen vorhanden') }}</flux:text>
@endforelse
</div>
</flux:card>
</div>
@endif
@if($activeTab === 'contacts')
<flux:card>
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<flux:heading size="lg">{{ __('Ansprechpartner') }} ({{ $filteredContactsTotal }})</flux:heading>
<div class="flex w-full gap-2 sm:w-auto">
<flux:input
wire:model.live.debounce.300ms="contactSearch"
placeholder="{{ __('Kontakte durchsuchen...') }}"
icon="magnifying-glass"
/>
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
<flux:button size="sm" icon="plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
{{ __('Neu') }}
</flux:button>
@endif
</div>
</div>
<div class="mb-4 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<flux:heading size="sm" class="mb-2">{{ __('Bestehenden Kontakt zuordnen') }}</flux:heading>
<flux:select
wire:model.live="selectedExistingContactId"
variant="combobox"
:filter="false"
placeholder="{{ __('Kontakt suchen und auswählen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="contactLookup"
placeholder="{{ __('Name oder E-Mail…') }}"
/>
</x-slot>
@foreach($contactLookupResults as $lookupContact)
@php($lookupName = trim(($lookupContact->first_name ?? '').' '.($lookupContact->last_name ?? '')) ?: __('Kontakt ohne Name'))
<flux:select.option :value="$lookupContact->id" wire:key="lc-{{ $lookupContact->id }}">
{{ $lookupName }}
<span class="text-zinc-400">
@if($lookupContact->email)
· {{ $lookupContact->email }}
@endif
· {{ $lookupContact->company?->name ?? __('Unbekannte Firma') }}
</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($contactLookup)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Kein Kontakt gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
</div>
<div class="space-y-3">
@forelse($filteredContacts as $contact)
<div class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<flux:text weight="semibold">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</flux:text>
<flux:badge color="{{ $this->portalBadgeColor($contact->portal) }}" size="sm">
{{ $contact->portal?->label() ?? __('Unbekannt') }}
</flux:badge>
</div>
<flux:text class="text-sm text-zinc-500">{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
@if($contact->email)
<flux:text class="text-sm text-blue-600 dark:text-blue-400">{{ $contact->email }}</flux:text>
@endif
</div>
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
@endif
</div>
</div>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Kontakte gefunden') }}</flux:text>
@endforelse
</div>
@if($filteredContactsTotal > $filteredContacts->count())
<flux:text class="mt-3 block text-xs text-zinc-500">
{{ __('Es werden die ersten :count Kontakte angezeigt. Bitte Suche eingrenzen, um weitere Treffer zu finden.', ['count' => $filteredContacts->count()]) }}
</flux:text>
@endif
</flux:card>
@endif
</div>

View file

@ -0,0 +1,275 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends Component
{
public int|string|null $companyId = null;
public string $companySearch = '';
public string $portal = '';
public ?string $salutationKey = null;
public ?string $title = null;
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $responsibility = null;
public ?string $phone = null;
public ?string $fax = null;
public ?string $email = null;
public bool $isCompanyPrefilled = false;
public function mount(?int $companyId = null): void
{
$prefilledCompanyId = $companyId ?: request()->integer('company');
if ($prefilledCompanyId > 0) {
$this->companyId = $prefilledCompanyId;
$company = Company::withoutGlobalScopes()->find($prefilledCompanyId);
$this->portal = $company?->portal?->value ?? Portal::Both->value;
$this->isCompanyPrefilled = true;
} else {
$this->portal = Portal::Both->value;
}
}
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
public function updatedCompanyId(): void
{
if (! $this->companyId) {
return;
}
$company = Company::withoutGlobalScopes()->find((int) $this->companyId);
if ($company) {
$this->portal = $company->portal?->value ?? Portal::Both->value;
}
}
public function save(): void
{
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],
'salutationKey' => ['nullable', 'string', 'max:20'],
'title' => ['nullable', 'string', 'max:80'],
'firstName' => ['nullable', 'string', 'max:80'],
'lastName' => ['nullable', 'string', 'max:80'],
'responsibility' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:40'],
'fax' => ['nullable', 'string', 'max:40'],
'email' => ['nullable', 'email', 'max:255'],
]);
$this->companySearch = '';
$contact = Contact::query()->create([
'company_id' => (int) $validated['companyId'],
'portal' => $validated['portal'],
'salutation_key' => $validated['salutationKey'] ?: null,
'title' => $validated['title'] ?: null,
'first_name' => $validated['firstName'] ?: null,
'last_name' => $validated['lastName'] ?: null,
'responsibility' => $validated['responsibility'] ?: null,
'phone' => $validated['phone'] ?: null,
'fax' => $validated['fax'] ?: null,
'email' => $validated['email'] ?: null,
]);
session()->flash('success', __('Kontakt wurde angelegt.'));
$this->redirect(route('admin.contacts.edit', $contact->id), navigate: true);
}
public function with(): array
{
$term = trim($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
$q->where(function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
});
})
->when(blank($term) && $this->companyId, function ($q): void {
// Aktuell gewählte Firma immer einschließen, damit das Combobox-Label korrekt angezeigt wird
$q->whereIn('id', [(int) $this->companyId]);
})
->when(blank($term) && ! $this->companyId, function ($q): void {
// Ohne Suchbegriff und ohne Auswahl: keine Ergebnisse
$q->whereRaw('0 = 1');
})
->orderBy('name')
->limit(50)
->get(['id', 'name']);
return [
'companies' => $companies,
'salutations' => config('salutations.items', []),
'portalOptions' => Portal::cases(),
];
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Kontakt anlegen') }}</flux:heading>
<flux:subheading>{{ __('Kontakt einer Firma zuordnen und Stammdaten erfassen.') }}</flux:subheading>
@if($isCompanyPrefilled && $companyId)
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Firma wurde vorausgewählt. Du kannst sie bei Bedarf trotzdem ändern.') }}
</flux:text>
@endif
</flux:card>
<flux:card>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Firma') }}</flux:label>
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma auswählen...') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Kontaktdaten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Anrede') }}</flux:label>
<flux:select wire:model="salutationKey">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($salutations as $key => $labels)
<option value="{{ $key }}">{{ $labels['de'] ?? $key }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Vorname') }}</flux:label>
<flux:input wire:model="firstName" />
<flux:error name="firstName" />
</flux:field>
<flux:field>
<flux:label>{{ __('Nachname') }}</flux:label>
<flux:input wire:model="lastName" />
<flux:error name="lastName" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Verantwortlichkeit') }}</flux:label>
<flux:input wire:model="responsibility" />
<flux:error name="responsibility" />
</flux:field>
<flux:field>
<flux:label>{{ __('E-Mail') }}</flux:label>
<flux:input wire:model="email" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="phone" />
<flux:error name="phone" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Fax') }}</flux:label>
<flux:input wire:model="fax" />
<flux:error name="fax" />
</flux:field>
</div>
</flux:card>
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.contacts.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Kontakt anlegen') }}
</flux:button>
</div>
</flux:card>
</form>

View file

@ -0,0 +1,352 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public int|string|null $companyId = null;
public string $companySearch = '';
public string $portal = '';
public ?string $salutationKey = null;
public ?string $title = null;
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $responsibility = null;
public ?string $phone = null;
public ?string $fax = null;
public ?string $email = null;
public function mount(int $id): void
{
$this->id = $id;
$contact = Contact::query()->findOrFail($id);
$this->companyId = $contact->company_id;
$this->portal = $contact->portal?->value ?? Portal::Both->value;
$this->salutationKey = $contact->salutation_key;
$this->title = $contact->title;
$this->firstName = $contact->first_name;
$this->lastName = $contact->last_name;
$this->responsibility = $contact->responsibility;
$this->phone = $contact->phone;
$this->fax = $contact->fax;
$this->email = $contact->email;
}
public function save(): void
{
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],
'salutationKey' => ['nullable', 'string', 'max:20'],
'title' => ['nullable', 'string', 'max:80'],
'firstName' => ['nullable', 'string', 'max:80'],
'lastName' => ['nullable', 'string', 'max:80'],
'responsibility' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:40'],
'fax' => ['nullable', 'string', 'max:40'],
'email' => ['nullable', 'email', 'max:255'],
]);
$contact = Contact::query()->findOrFail($this->id);
$contact->update([
'company_id' => (int) $validated['companyId'],
'portal' => $validated['portal'],
'salutation_key' => $validated['salutationKey'] ?: null,
'title' => $validated['title'] ?: null,
'first_name' => $validated['firstName'] ?: null,
'last_name' => $validated['lastName'] ?: null,
'responsibility' => $validated['responsibility'] ?: null,
'phone' => $validated['phone'] ?: null,
'fax' => $validated['fax'] ?: null,
'email' => $validated['email'] ?: null,
]);
session()->flash('success', __('Kontakt wurde aktualisiert.'));
$this->redirect(route('admin.contacts.index'), navigate: true);
}
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
public function updatedCompanyId(): void
{
if (! $this->companyId) {
return;
}
$company = Company::withoutGlobalScopes()->find((int) $this->companyId);
if ($company) {
$this->portal = $company->portal?->value ?? Portal::Both->value;
}
}
public function with(): array
{
$term = trim($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
$q->where(function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
});
})
->when(blank($term) && $this->companyId, function ($q): void {
// Aktuell gewählte Firma immer einschließen, damit Label + Modal-Text korrekt sind
$q->whereIn('id', [(int) $this->companyId]);
})
->when(blank($term) && ! $this->companyId, function ($q): void {
$q->whereRaw('0 = 1');
})
->orderBy('name')
->limit(50)
->get(['id', 'name']);
return [
'companies' => $companies,
'salutations' => config('salutations.items', []),
'portalOptions' => Portal::cases(),
];
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
public function deleteContact(): void
{
$contact = Contact::query()->find($this->id);
if (! $contact) {
session()->flash('error', __('Der angeforderte Kontakt wurde nicht gefunden.'));
$this->redirect(route('admin.contacts.index'), navigate: true);
return;
}
$contact->delete();
session()->flash('success', __('Kontakt wurde gelöscht.'));
$this->redirect(route('admin.contacts.index'), navigate: true);
}
private function currentPortalLabel(): string
{
return Portal::tryFrom($this->portal)?->label() ?? __('Unbekannt');
}
private function currentPortalBadgeColor(): string
{
return match (Portal::tryFrom($this->portal)) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
}; ?>
<div class="space-y-6">
<form wire:submit="save" class="space-y-6">
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Kontakt bearbeiten') }}</flux:heading>
<flux:subheading>ID: {{ $id }}</flux:subheading>
</div>
<flux:badge color="{{ $this->currentPortalBadgeColor() }}" size="sm">
{{ $this->currentPortalLabel() }}
</flux:badge>
</div>
</flux:card>
<flux:card>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Firma') }}</flux:label>
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma auswählen...') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Kontaktdaten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Anrede') }}</flux:label>
<flux:select wire:model="salutationKey">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($salutations as $key => $labels)
<option value="{{ $key }}">{{ $labels['de'] ?? $key }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Vorname') }}</flux:label>
<flux:input wire:model="firstName" />
<flux:error name="firstName" />
</flux:field>
<flux:field>
<flux:label>{{ __('Nachname') }}</flux:label>
<flux:input wire:model="lastName" />
<flux:error name="lastName" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Verantwortlichkeit') }}</flux:label>
<flux:input wire:model="responsibility" />
<flux:error name="responsibility" />
</flux:field>
<flux:field>
<flux:label>{{ __('E-Mail') }}</flux:label>
<flux:input wire:model="email" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Telefon') }}</flux:label>
<flux:input wire:model="phone" />
<flux:error name="phone" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Fax') }}</flux:label>
<flux:input wire:model="fax" />
<flux:error name="fax" />
</flux:field>
</div>
</flux:card>
<flux:card>
<div class="flex justify-between">
<flux:modal.trigger name="confirm-contact-deletion">
<flux:button
variant="danger"
icon="trash"
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-contact-deletion')"
>
{{ __('Löschen') }}
</flux:button>
</flux:modal.trigger>
<div class="flex gap-3">
<flux:button variant="ghost" href="{{ route('admin.contacts.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
</flux:card>
</form>
<flux:modal name="confirm-contact-deletion" class="max-w-lg">
@php
$contactDisplayName = trim(($firstName ?? '').' '.($lastName ?? '')) ?: __('Kontakt ohne Name');
$selectedCompanyName = $companies->firstWhere('id', (int) $companyId)?->name ?? __('Unbekannte Firma');
@endphp
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kontakt wirklich löschen?') }}</flux:heading>
<flux:subheading>
{{ __('Du löschst: :contact (Firma: :company). Dieser Kontakt wird archiviert (Soft Delete) und aus den Standardlisten entfernt.', ['contact' => $contactDisplayName, 'company' => $selectedCompanyName]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deleteContact">
{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,729 @@
<?php
use App\Enums\Portal;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Models\UserFilterPreset;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\CurrentPortalContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Component
{
use WithPagination;
public string $search = '';
#[Url(as: 'company', except: 'all')]
public string $companyFilter = 'all';
public string $companySearch = '';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
public string $userSearch = '';
#[Url(as: 'data', except: 'all')]
public string $qualityFilter = 'all';
public string $portalFilter = 'all';
public ?int $selectedPresetId = null;
public string $presetName = '';
public string $notification = '';
public string $notificationType = 'success';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
public function updatedCompanyFilter(): void
{
// Flux clearable setzt den Wert auf null normalisieren auf 'all'
if (blank($this->companyFilter)) {
$this->companyFilter = 'all';
}
$this->companySearch = '';
$this->resetPage();
}
public function updatedCompanySearch(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
if (blank($this->userFilter)) {
$this->userFilter = 'all';
}
$this->userSearch = '';
$this->resetPage();
}
public function updatedUserSearch(): void
{
$this->resetPage();
}
public function clearCompanySearch(): void
{
$this->companyFilter = 'all';
$this->companySearch = '';
$this->resetPage();
}
public function clearUserSearch(): void
{
$this->userFilter = 'all';
$this->userSearch = '';
$this->resetPage();
}
public function updatedQualityFilter(): void
{
$this->resetPage();
}
public function mount(): void
{
$currentUser = auth()->user();
if (! $currentUser) {
return;
}
$defaultPreset = UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->where('is_default', true)->first();
if (! $defaultPreset) {
return;
}
$this->selectedPresetId = (int) $defaultPreset->id;
$filters = $defaultPreset->filters ?? [];
$this->search = (string) ($filters['search'] ?? '');
$this->companyFilter = (string) ($filters['company_filter'] ?? 'all');
$this->userFilter = (string) ($filters['user_filter'] ?? 'all');
$this->qualityFilter = (string) ($filters['quality_filter'] ?? 'all');
$this->portalFilter = (string) ($filters['portal_filter'] ?? 'all');
}
public function with(): array
{
$currentUser = auth()->user();
$contacts = Contact::query()
->with('company:id,name')
->withCount('pressReleases')
->when($this->search, function ($query): void {
$term = trim($this->search);
if ($this->supportsFullTextSearch($term)) {
$query->where(function ($query) use ($term): void {
$query->whereFullText(['first_name', 'last_name', 'email', 'responsibility'], $term)
->orWhereHas('company', fn ($companyQuery) => $companyQuery->whereFullText(['name', 'email', 'slug'], $term));
});
return;
}
$query->where(function ($searchQuery): void {
$searchQuery
->where('first_name', 'like', '%'.$this->search.'%')
->orWhere('last_name', 'like', '%'.$this->search.'%')
->orWhere('email', 'like', '%'.$this->search.'%')
->orWhereHas('company', function ($companyQuery): void {
$companyQuery->where('name', 'like', '%'.$this->search.'%');
});
});
})
->when($this->companyFilter !== 'all', function ($query): void {
$query->where('company_id', (int) $this->companyFilter);
})
->when($this->userFilter !== 'all', function ($query): void {
$query->whereHas('users', fn ($userQuery) => $userQuery->where('users.id', (int) $this->userFilter));
})
->when($this->qualityFilter !== 'all', function ($query): void {
match ($this->qualityFilter) {
'with_press_releases' => $query->whereHas('pressReleases'),
'without_press_releases' => $query->whereDoesntHave('pressReleases'),
default => null,
};
})
->when($this->portalFilter !== 'all', function ($query): void {
$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);
// Firmen-Filter: nur Live-Suche, nie alle laden
$term = trim($this->companySearch);
$selectedCompanyId = $this->companyFilter !== 'all' ? (int) $this->companyFilter : null;
$filterCompanies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%');
})
->when(blank($term) && $selectedCompanyId, fn ($q) => $q->whereIn('id', [$selectedCompanyId]))
->when(blank($term) && ! $selectedCompanyId, fn ($q) => $q->whereRaw('0 = 1'))
->orderBy('name')
->limit(50)
->get(['id', 'name']);
$userTerm = trim($this->userSearch);
$selectedUserId = $this->userFilter !== 'all' ? (int) $this->userFilter : null;
$filterUsers = User::query()
->select(['id', 'name', 'email'])
->where(function ($query) use ($userTerm, $selectedUserId): void {
if ($selectedUserId) {
$query->where('id', $selectedUserId);
}
if ($userTerm !== '') {
$query->orWhere(function ($searchQuery) use ($userTerm): void {
$searchQuery
->where('name', 'like', '%'.$userTerm.'%')
->orWhere('email', 'like', '%'.$userTerm.'%');
});
}
})
->when($userTerm === '' && ! $selectedUserId, fn ($query) => $query->whereRaw('0 = 1'))
->orderBy('name')
->limit(20)
->get();
return [
'contacts' => $contacts,
'filterCompanies' => $filterCompanies,
'filterUsers' => $filterUsers,
'portalOptions' => Portal::cases(),
'presets' => $currentUser
? UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->orderByDesc('is_default')
->orderByDesc('last_used_at')
->orderBy('name')
->get(['id', 'name', 'is_default', 'last_used_at', 'filters'])
: collect(),
'stats' => $this->stats(),
];
}
/**
* @return array{total: int, companies_with_contacts: int, avg_per_company: float}
*/
private function stats(): array
{
$portal = CurrentPortalContext::get()?->value ?? 'all';
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->contactsStatsKey($portal), AdminPerformanceCache::StatsTtl, function (): array {
$total = Contact::count();
$companiesWithContacts = Contact::query()
->distinct()
->count('company_id');
return [
'total' => $total,
'companies_with_contacts' => $companiesWithContacts,
'avg_per_company' => $companiesWithContacts > 0 ? round($total / $companiesWithContacts, 1) : 0.0,
];
});
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function savePreset(): void
{
$currentUser = auth()->user();
if (! $currentUser) {
return;
}
$validated = $this->validate([
'presetName' => ['required', 'string', 'min:2', 'max:120', Rule::unique('user_filter_presets', 'name')->where(fn ($query) => $query->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index'))],
]);
UserFilterPreset::query()->create([
'user_id' => $currentUser->id,
'page' => 'admin.contacts.index',
'name' => $validated['presetName'],
'last_used_at' => now(),
'filters' => [
'search' => $this->search,
'company_filter' => $this->companyFilter,
'user_filter' => $this->userFilter,
'quality_filter' => $this->qualityFilter,
'portal_filter' => $this->portalFilter,
],
]);
$this->presetName = '';
$this->notification = __('Filter-Preset wurde gespeichert.');
$this->notificationType = 'success';
}
public function applyPreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
$preset = UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->find($this->selectedPresetId);
if (! $preset) {
return;
}
$filters = $preset->filters ?? [];
$this->search = (string) ($filters['search'] ?? '');
$this->companyFilter = (string) ($filters['company_filter'] ?? 'all');
$this->userFilter = (string) ($filters['user_filter'] ?? 'all');
$this->qualityFilter = (string) ($filters['quality_filter'] ?? 'all');
$this->portalFilter = (string) ($filters['portal_filter'] ?? 'all');
$preset->update(['last_used_at' => now()]);
$this->resetPage();
}
public function deletePreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
UserFilterPreset::query()->where('user_id', $currentUser->id)->where('page', 'admin.contacts.index')->whereKey($this->selectedPresetId)->delete();
$this->selectedPresetId = null;
$this->notification = __('Filter-Preset wurde gelöscht.');
$this->notificationType = 'success';
}
public function setDefaultPreset(): void
{
$currentUser = auth()->user();
if (! $currentUser || ! $this->selectedPresetId) {
return;
}
UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->update(['is_default' => false]);
UserFilterPreset::query()
->where('user_id', $currentUser->id)
->where('page', 'admin.contacts.index')
->whereKey($this->selectedPresetId)
->update(['is_default' => true]);
$this->notification = __('Standard-Preset wurde gesetzt.');
$this->notificationType = 'success';
}
public function deleteContactFromIndex(int $contactId): void
{
$contact = Contact::query()->find($contactId);
if (! $contact) {
$this->notification = __('Der angeforderte Kontakt wurde nicht gefunden.');
$this->notificationType = 'error';
return;
}
$contact->delete();
$this->notification = __('Kontakt wurde gelöscht.');
$this->notificationType = 'success';
$this->resetPage();
}
private function portalBadgeColor(?Portal $portal): string
{
return match ($portal) {
Portal::Presseecho => 'blue',
Portal::Businessportal24 => 'purple',
Portal::Both => 'zinc',
default => 'zinc',
};
}
}; ?>
<div class="space-y-6">
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
class="rounded-md px-4 py-3 text-sm border
{{ $notificationType === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300' }}">
{{ $notification }}
</div>
@endif
{{-- Statistiken --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</div>
<flux:icon.user-group class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Firmen mit Kontakten') }}
</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['companies_with_contacts'] }}</flux:text>
</div>
<flux:icon.building-office class="size-8 text-green-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Ø pro Firma') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['avg_per_company'], 1) }}
</flux:text>
</div>
<flux:icon.chart-bar class="size-8 text-purple-500" />
</div>
</flux:card>
</div>
{{-- Filter & Actions --}}
<flux:card>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<flux:input wire:model.live.debounce.300ms="search"
placeholder="{{ __('Name, Email oder Firma suchen...') }}" icon="magnifying-glass" class="flex-1" />
<div class="flex w-full gap-2 xl:w-64">
<flux:select wire:model.live="companyFilter" variant="combobox" :filter="false" clearable
placeholder="{{ __('Alle Firmen') }}" class="min-w-0 flex-1">
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Firma suchen…') }}" />
</x-slot>
@foreach ($filterCompanies as $company)
<flux:select.option :value="$company->id" wire:key="fc-{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($companySearch)))
{{ __('Name eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearCompanySearch"
title="{{ __('Firmensuche zurücksetzen') }}"
/>
</div>
<div class="flex w-full gap-2 xl:w-64">
<flux:select wire:model.live="userFilter" variant="combobox" :filter="false" clearable
placeholder="{{ __('Alle User') }}" class="min-w-0 flex-1">
<x-slot name="input">
<flux:select.input wire:model.live.debounce.300ms="userSearch"
placeholder="{{ __('User suchen…') }}" />
</x-slot>
@foreach ($filterUsers as $user)
<flux:select.option :value="$user->id" wire:key="contact-user-{{ $user->id }}">
{{ $user->name }}
<span class="ml-1 text-zinc-400">· {{ $user->email }}</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($userSearch)))
{{ __('Usernamen oder E-Mail eingeben…') }}
@else
{{ __('Kein User gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearUserSearch"
title="{{ __('Usersuche zurücksetzen') }}"
/>
</div>
<flux:select wire:model.live="qualityFilter" class="w-full xl:w-56">
<option value="all">{{ __('Alle Datenstände') }}</option>
<option value="with_press_releases">{{ __('Mit Pressemitteilungen') }}</option>
<option value="without_press_releases">{{ __('Ohne Pressemitteilungen') }}</option>
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full xl:w-48">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.create'))
<flux:button icon="plus" href="{{ route('admin.contacts.create') }}" wire:navigate>
{{ __('Neuer Kontakt') }}
</flux:button>
@else
<flux:button icon="plus" disabled>
{{ __('Neuer Kontakt') }}
</flux:button>
@endif
</div>
</div>
</flux:card>
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 gap-3">
<flux:input wire:model="presetName" placeholder="{{ __('Neues Preset speichern...') }}"
class="flex-1" />
<flux:button wire:click="savePreset" variant="subtle" icon="bookmark">
{{ __('Preset speichern') }}
</flux:button>
</div>
<div class="flex gap-3">
<flux:select wire:model="selectedPresetId" class="w-64">
<option value="">{{ __('Preset auswählen') }}</option>
@foreach ($presets as $preset)
<option value="{{ $preset->id }}">
{{ $preset->name }}{{ $preset->is_default ? ' (Standard)' : '' }}
</option>
@endforeach
</flux:select>
<flux:button wire:click="applyPreset" variant="ghost">{{ __('Anwenden') }}</flux:button>
<flux:button wire:click="setDefaultPreset" variant="ghost">{{ __('Als Standard') }}</flux:button>
<flux:button wire:click="deletePreset" variant="danger">{{ __('Löschen') }}</flux:button>
</div>
</div>
<flux:error name="presetName" class="mt-3" />
</flux:card>
{{-- Tabelle --}}
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'last_name'" :direction="$sortDir"
wire:click="sort('last_name')">{{ __('Name') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'email'" :direction="$sortDir"
wire:click="sort('email')">{{ __('Kontakt') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'company_id'" :direction="$sortDir"
wire:click="sort('company_id')">{{ __('Firma') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'press_releases_count'" :direction="$sortDir"
wire:click="sort('press_releases_count')">{{ __('PMs') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
wire:click="sort('created_at')">{{ __('Hinzugefügt') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($contacts as $contact)
@php
$contactDisplayName =
trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?:
__('Kontakt ohne Name');
$contactCompanyName = $contact->company?->name ?? __('Unbekannte Firma');
@endphp
<flux:table.row :key="$contact->id">
<flux:table.cell>
<div class="flex gap-2">
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
@endif
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
<flux:button size="sm" variant="ghost" icon="building-office"
href="{{ route('admin.companies.show', $contact->company_id) }}"
wire:navigate />
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div>
<flux:text weight="semibold truncate">
{{ $contactDisplayName }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-1">
<flux:text class="text-sm">
<a href="mailto:{{ $contact->email }}"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ $contact->email ?: __('Keine E-Mail') }}
</a>
</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $contact->phone ?: __('Kein Telefon') }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
<a href="{{ route('admin.companies.show', $contact->company_id) }}" wire:navigate
class="text-blue-600 hover:underline dark:text-blue-400">
{{ \Illuminate\Support\Str::limit($contact->company->name, 60) }}
</a>
@else
<flux:text>
{{ \Illuminate\Support\Str::limit($contact->company?->name ?? __('Unbekannte Firma'), 80) }}
</flux:text>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ $this->portalBadgeColor($contact->portal) }}" size="sm">
{{ $contact->portal?->label() ?? __('Unbekannt') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if ($contact->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
href="{{ route('admin.press-releases.index', ['contact' => $contact->id]) }}"
wire:navigate
>
{{ $contact->press_releases_count }} {{ __('PMs') }}
</flux:button>
@else
<flux:badge color="zinc" size="sm">0</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">
{{ $contact->created_at?->format('d.m.Y H:i') ?? '-' }}
</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex gap-2">
<flux:modal.trigger name="confirm-contact-delete-{{ $contact->id }}">
<flux:button size="sm" variant="ghost" icon="trash" type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-contact-delete-{{ $contact->id }}')" />
</flux:modal.trigger>
<flux:button size="sm" variant="ghost" icon="envelope"
href="mailto:{{ $contact->email }}" />
</div>
<flux:modal name="confirm-contact-delete-{{ $contact->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kontakt wirklich löschen?') }}
</flux:heading>
<flux:subheading>
{{ __('Du löschst: :contact (Firma: :company). Dieser Kontakt wird archiviert (Soft Delete) und aus den Standardlisten entfernt.', ['contact' => $contactDisplayName, 'company' => $contactCompanyName]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger"
wire:click="deleteContactFromIndex({{ $contact->id }})">
{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center py-12">
<flux:icon.user-group class="size-12 text-zinc-400 dark:text-zinc-600" />
<flux:text class="mt-4 text-zinc-500">{{ __('Keine Kontakte gefunden') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
{{ $contacts->links() }}
</div>
</flux:card>
</div>

View file

@ -0,0 +1,43 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Gutscheine')] class extends Component
{
public function with(): array
{
return [];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<flux:heading size="lg">{{ __('Gutscheine') }}</flux:heading>
<flux:subheading>
{{ __('Coupons sind in der Initialmigration vertagt (Entscheidung D-16). Eine Wiedereinführung wird später separat evaluiert ggf. direkt über Stripe-Coupons.') }}
</flux:subheading>
</div>
<flux:badge color="zinc" icon="pause" size="lg">
{{ __('Vertagt') }}
</flux:badge>
</div>
</flux:card>
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Hinweise') }}</flux:heading>
<ul class="space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li class="flex gap-2">
<flux:icon.information-circle class="size-5 shrink-0 text-zinc-400" />
<span>{{ __('Im neuen Stack sind keine eigenen Coupon-Tabellen vorgesehen. Sobald wieder benötigt, werden Coupons als Stripe-Coupons abgebildet.') }}</span>
</li>
<li class="flex gap-2">
<flux:icon.information-circle class="size-5 shrink-0 text-zinc-400" />
<span>{{ __('Bestehende Legacy-Gutscheine werden nicht migriert Bestandskunden behalten ihre Konditionen über das Grandfathering-Modell (P8.8).') }}</span>
</li>
</ul>
</flux:card>
</div>

View file

@ -0,0 +1,201 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\FooterCode;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class extends Component
{
#[Validate('required|string|min:2|max:255')]
public string $title = '';
#[Validate('required|string|min:5')]
public string $content = '';
#[Validate('required|in:'.Portal::Both->value.','.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
public string $portal = Portal::Both->value;
#[Validate('nullable|in:de,en')]
public ?string $language = null;
public bool $isGlobal = false;
public bool $isActive = true;
#[Validate('integer|min:0|max:1000')]
public int $priority = 0;
/** @var array<int, int> */
public array $categoryIds = [];
public function save(): void
{
$this->validate();
DB::transaction(function (): void {
$code = FooterCode::create([
'title' => $this->title,
'content' => $this->content,
'portal' => $this->portal,
'language' => $this->language,
'is_global' => $this->isGlobal,
'is_active' => $this->isActive,
'priority' => $this->priority,
]);
if (! $this->isGlobal && ! empty($this->categoryIds)) {
$code->categories()->sync($this->categoryIds);
}
});
session()->flash('success', __('Footer-Code wurde angelegt.'));
$this->redirect(route('admin.footer-codes.index'), navigate: true);
}
public function with(): array
{
return [
'portalOptions' => Portal::cases(),
'categoryOptions' => Category::query()
->with(['translations' => fn ($q) => $q->where('locale', 'de')])
->orderBy('id')
->get()
->map(fn (Category $cat) => [
'id' => $cat->id,
'name' => $cat->translations->first()?->name ?? '#'.$cat->id,
'portal' => $cat->portal->value,
]),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl">{{ __('Footer-Code anlegen') }}</flux:heading>
<flux:subheading>
{{ __('Snippet, das unter Pressemitteilungen ausgespielt wird.') }}
</flux:subheading>
</div>
<flux:button
variant="ghost"
icon="arrow-left"
:href="route('admin.footer-codes.index')"
wire:navigate
>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<form wire:submit="save" class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Stammdaten') }}</flux:heading>
<div class="mt-4 space-y-4">
<flux:input
wire:model="title"
:label="__('Titel')"
placeholder="z. B. {{ __('Standard-Disclaimer DE') }}"
/>
<flux:textarea
wire:model="content"
:label="__('HTML-/Text-Inhalt')"
rows="10"
placeholder="<p>…</p>"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<flux:select wire:model="portal" :label="__('Portal')">
@foreach($portalOptions as $option)
<option value="{{ $option->value }}">{{ $option->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model="language" :label="__('Sprache')">
<option value="">{{ __('Alle') }}</option>
<option value="de">{{ __('Deutsch') }}</option>
<option value="en">{{ __('Englisch') }}</option>
</flux:select>
<flux:input
wire:model="priority"
type="number"
min="0"
max="1000"
:label="__('Priorität')"
:description="__('Niedrigere Werte zuerst.')"
/>
</div>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg">{{ __('Sichtbarkeit') }}</flux:heading>
<div class="mt-4 space-y-4">
<flux:switch
wire:model.live="isGlobal"
:label="__('Global ausspielen')"
:description="__('Wird unter allen Pressemitteilungen angezeigt Kategorie-Zuordnung wird ignoriert.')"
/>
<flux:switch
wire:model="isActive"
:label="__('Aktiv')"
:description="__('Inaktive Codes werden niemals ausgespielt.')"
/>
</div>
</flux:card>
@if(! $isGlobal)
<flux:card>
<flux:heading size="lg">{{ __('Kategorie-Zuordnung') }}</flux:heading>
<flux:subheading>
{{ __('Nur Pressemitteilungen in diesen Kategorien zeigen den Footer-Code.') }}
</flux:subheading>
<div class="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
@forelse($categoryOptions as $option)
<label class="flex items-center gap-2 rounded border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<input
type="checkbox"
wire:model="categoryIds"
value="{{ $option['id'] }}"
class="rounded border-zinc-300"
/>
<span class="text-sm">{{ $option['name'] }}</span>
</label>
@empty
<flux:text class="text-zinc-500">
{{ __('Keine Kategorien vorhanden.') }}
</flux:text>
@endforelse
</div>
</flux:card>
@endif
<flux:card>
<div class="flex items-center justify-end gap-2">
<flux:button
variant="ghost"
:href="route('admin.footer-codes.index')"
wire:navigate
>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">
{{ __('Speichern') }}
</flux:button>
</div>
</flux:card>
</form>
</div>

View file

@ -0,0 +1,232 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use App\Models\FooterCode;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class extends Component
{
public int $id = 0;
#[Validate('required|string|min:2|max:255')]
public string $title = '';
#[Validate('required|string|min:5')]
public string $content = '';
#[Validate('required|in:'.Portal::Both->value.','.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
public string $portal = Portal::Both->value;
#[Validate('nullable|in:de,en')]
public ?string $language = null;
public bool $isGlobal = false;
public bool $isActive = true;
#[Validate('integer|min:0|max:1000')]
public int $priority = 0;
/** @var array<int, int> */
public array $categoryIds = [];
public function mount(int $id): void
{
$code = FooterCode::query()
->with('categories:id')
->findOrFail($id);
$this->id = $code->id;
$this->title = $code->title;
$this->content = $code->content;
$this->portal = $code->portal->value;
$this->language = $code->language;
$this->isGlobal = $code->is_global;
$this->isActive = $code->is_active;
$this->priority = $code->priority;
$this->categoryIds = $code->categories->pluck('id')->all();
}
public function save(): void
{
$this->validate();
$code = FooterCode::query()->findOrFail($this->id);
DB::transaction(function () use ($code): void {
$code->update([
'title' => $this->title,
'content' => $this->content,
'portal' => $this->portal,
'language' => $this->language,
'is_global' => $this->isGlobal,
'is_active' => $this->isActive,
'priority' => $this->priority,
]);
$code->categories()->sync(
$this->isGlobal ? [] : $this->categoryIds,
);
});
session()->flash('success', __('Footer-Code wurde aktualisiert.'));
$this->redirect(route('admin.footer-codes.index'), navigate: true);
}
public function delete(): void
{
$code = FooterCode::query()->findOrFail($this->id);
$code->delete();
session()->flash('success', __('Footer-Code wurde gelöscht.'));
$this->redirect(route('admin.footer-codes.index'), navigate: true);
}
public function with(): array
{
return [
'portalOptions' => Portal::cases(),
'categoryOptions' => Category::query()
->with(['translations' => fn ($q) => $q->where('locale', 'de')])
->orderBy('id')
->get()
->map(fn (Category $cat) => [
'id' => $cat->id,
'name' => $cat->translations->first()?->name ?? '#'.$cat->id,
'portal' => $cat->portal->value,
]),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl">{{ __('Footer-Code bearbeiten') }}</flux:heading>
<flux:subheading>#{{ $id }} {{ $title }}</flux:subheading>
</div>
<flux:button
variant="ghost"
icon="arrow-left"
:href="route('admin.footer-codes.index')"
wire:navigate
>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<form wire:submit="save" class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Stammdaten') }}</flux:heading>
<div class="mt-4 space-y-4">
<flux:input wire:model="title" :label="__('Titel')" />
<flux:textarea
wire:model="content"
:label="__('HTML-/Text-Inhalt')"
rows="10"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<flux:select wire:model="portal" :label="__('Portal')">
@foreach($portalOptions as $option)
<option value="{{ $option->value }}">{{ $option->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model="language" :label="__('Sprache')">
<option value="">{{ __('Alle') }}</option>
<option value="de">{{ __('Deutsch') }}</option>
<option value="en">{{ __('Englisch') }}</option>
</flux:select>
<flux:input
wire:model="priority"
type="number"
min="0"
max="1000"
:label="__('Priorität')"
/>
</div>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg">{{ __('Sichtbarkeit') }}</flux:heading>
<div class="mt-4 space-y-4">
<flux:switch
wire:model.live="isGlobal"
:label="__('Global ausspielen')"
:description="__('Wird unter allen Pressemitteilungen angezeigt Kategorie-Zuordnung wird ignoriert.')"
/>
<flux:switch
wire:model="isActive"
:label="__('Aktiv')"
/>
</div>
</flux:card>
@if(! $isGlobal)
<flux:card>
<flux:heading size="lg">{{ __('Kategorie-Zuordnung') }}</flux:heading>
<div class="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
@forelse($categoryOptions as $option)
<label class="flex items-center gap-2 rounded border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<input
type="checkbox"
wire:model="categoryIds"
value="{{ $option['id'] }}"
class="rounded border-zinc-300"
/>
<span class="text-sm">{{ $option['name'] }}</span>
</label>
@empty
<flux:text class="text-zinc-500">
{{ __('Keine Kategorien vorhanden.') }}
</flux:text>
@endforelse
</div>
</flux:card>
@endif
<flux:card>
<div class="flex items-center justify-between gap-2">
<flux:button
type="button"
variant="danger"
icon="trash"
wire:click="delete"
wire:confirm="{{ __('Footer-Code wirklich löschen?') }}"
>
{{ __('Löschen') }}
</flux:button>
<div class="flex items-center gap-2">
<flux:button
variant="ghost"
:href="route('admin.footer-codes.index')"
wire:navigate
>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
</flux:card>
</form>
</div>

View file

@ -0,0 +1,240 @@
<?php
use App\Enums\Portal;
use App\Models\FooterCode;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Component
{
use WithPagination;
public string $search = '';
public string $portalFilter = 'all';
public string $statusFilter = 'all';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function toggleActive(int $id): void
{
$footerCode = FooterCode::query()->find($id);
if (! $footerCode) {
return;
}
$footerCode->update(['is_active' => ! $footerCode->is_active]);
}
public function with(): array
{
$term = trim($this->search);
$codes = FooterCode::query()
->withCount('categories')
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->statusFilter === 'active', fn ($q) => $q->where('is_active', true))
->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false))
->when($this->statusFilter === 'global', fn ($q) => $q->where('is_global', true))
->when(filled($term), function ($q) use ($term): void {
$q->where(function ($q) use ($term): void {
$q->where('title', 'like', '%'.$term.'%')
->orWhere('content', 'like', '%'.$term.'%');
});
})
->orderByDesc('is_global')
->orderBy('priority')
->orderBy('title')
->paginate(25);
return [
'codes' => $codes,
'portalOptions' => Portal::cases(),
'totals' => [
'total' => FooterCode::query()->count(),
'active' => FooterCode::query()->where('is_active', true)->count(),
'global' => FooterCode::query()->where('is_global', true)->count(),
],
];
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<flux:callout color="green" icon="check-circle">{{ session('success') }}</flux:callout>
@endif
@if(session('error'))
<flux:callout color="red" icon="exclamation-triangle">{{ session('error') }}</flux:callout>
@endif
<flux:card>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<flux:heading size="xl">{{ __('Footer-Codes') }}</flux:heading>
<flux:subheading>
{{ __('Snippets, die unter Pressemitteilungen ausgespielt werden.') }}
</flux:subheading>
</div>
<flux:button
variant="primary"
icon="plus"
:href="route('admin.footer-codes.create')"
wire:navigate
>
{{ __('Footer-Code anlegen') }}
</flux:button>
</div>
</flux:card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $totals['total'] }}</flux:text>
</div>
<flux:icon.code-bracket-square class="size-8 text-blue-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500">{{ __('Aktiv') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $totals['active'] }}</flux:text>
</div>
<flux:icon.bolt class="size-8 text-green-500" />
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:text class="text-sm text-zinc-500">{{ __('Global') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $totals['global'] }}</flux:text>
</div>
<flux:icon.globe-alt class="size-8 text-purple-500" />
</div>
</flux:card>
</div>
<flux:card>
<div class="grid gap-3 md:grid-cols-[1fr,auto,auto]">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Titel oder Inhalt suchen…') }}"
icon="magnifying-glass"
/>
<flux:select wire:model.live="portalFilter">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach($portalOptions as $option)
<option value="{{ $option->value }}">{{ $option->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="statusFilter">
<option value="all">{{ __('Alle Status') }}</option>
<option value="active">{{ __('Aktiv') }}</option>
<option value="inactive">{{ __('Inaktiv') }}</option>
<option value="global">{{ __('Global') }}</option>
</flux:select>
</div>
</flux:card>
<flux:card>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Sprache') }}</flux:table.column>
<flux:table.column>{{ __('Kategorien') }}</flux:table.column>
<flux:table.column>{{ __('Priorität') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column align="end">{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($codes as $code)
<flux:table.row :key="'fc-'.$code->id">
<flux:table.cell>
<div class="flex items-center gap-2">
@if($code->is_global)
<flux:badge color="purple" size="xs" icon="globe-alt">{{ __('Global') }}</flux:badge>
@endif
<span class="font-medium">{{ $code->title }}</span>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="zinc" size="sm">{{ $code->portal->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
{{ $code->language ? strtoupper($code->language) : '' }}
</flux:table.cell>
<flux:table.cell>
@if($code->is_global)
<span class="text-zinc-500">{{ __('alle') }}</span>
@else
{{ $code->categories_count }}
@endif
</flux:table.cell>
<flux:table.cell>{{ $code->priority }}</flux:table.cell>
<flux:table.cell>
<flux:badge :color="$code->is_active ? 'green' : 'zinc'" size="sm">
{{ $code->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell align="end">
<div class="flex items-center justify-end gap-1">
<flux:button
size="xs"
variant="ghost"
:icon="$code->is_active ? 'pause' : 'play'"
wire:click="toggleActive({{ $code->id }})"
:title="$code->is_active ? __('Deaktivieren') : __('Aktivieren')"
/>
<flux:button
size="xs"
variant="ghost"
icon="pencil"
:href="route('admin.footer-codes.edit', $code->id)"
wire:navigate
:title="__('Bearbeiten')"
/>
</div>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell :colspan="7">
<div class="py-8 text-center text-zinc-500">
{{ __('Keine Footer-Codes gefunden.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<div class="mt-4">
{{ $codes->links() }}
</div>
</flux:card>
</div>

View file

@ -0,0 +1,314 @@
<?php
use App\Enums\Portal;
use App\Models\LegacyInvoice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $portalFilter = 'all';
public string $statusFilter = 'all';
public string $mappingFilter = 'all';
public string $pdfFilter = 'all';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedMappingFilter(): void
{
$this->resetPage();
}
public function updatedPdfFilter(): void
{
$this->resetPage();
}
public function resetFilters(): void
{
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter']);
$this->resetPage();
}
public function with(): array
{
$baseQuery = LegacyInvoice::query();
$filteredQuery = $this->filteredQuery();
$supportsPdfGeneratedAt = $this->supportsPdfGeneratedAt();
return [
'invoices' => $filteredQuery
->with('user:id,name,email')
->latest('invoice_date')
->latest('id')
->paginate(50),
'statusOptions' => (clone $baseQuery)
->whereNotNull('status')
->distinct()
->orderBy('status')
->pluck('status')
->filter()
->values(),
'portalOptions' => collect([Portal::Presseecho, Portal::Businessportal24]),
'stats' => [
'count' => (clone $baseQuery)->count(),
'total_cents' => (int) (clone $baseQuery)->sum('total_cents'),
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count(),
'unmapped_count' => (clone $baseQuery)->whereNull('user_id')->count(),
'generated_pdf_count' => $supportsPdfGeneratedAt
? (clone $baseQuery)->whereNotNull('pdf_generated_at')->count()
: 0,
'filtered_count' => (clone $filteredQuery)->count(),
],
'supportsPdfGeneratedAt' => $supportsPdfGeneratedAt,
];
}
private function filteredQuery(): Builder
{
return LegacyInvoice::query()
->when(filled($this->search), function (Builder $query): void {
$search = trim($this->search);
$query->where(function (Builder $query) use ($search): void {
$query
->where('number', 'like', '%'.$search.'%')
->orWhere('legacy_id', $search)
->orWhere('legacy_user_id', $search)
->orWhereHas('user', function (Builder $query) use ($search): void {
$query
->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%');
});
});
})
->when($this->portalFilter !== 'all', fn (Builder $query) => $query->where('legacy_portal', $this->portalFilter))
->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter))
->when($this->mappingFilter === 'mapped', fn (Builder $query) => $query->whereNotNull('user_id'))
->when($this->mappingFilter === 'unmapped', fn (Builder $query) => $query->whereNull('user_id'))
->when($this->supportsPdfGeneratedAt() && $this->pdfFilter === 'generated', fn (Builder $query) => $query->whereNotNull('pdf_generated_at'))
->when($this->supportsPdfGeneratedAt() && $this->pdfFilter === 'pending', fn (Builder $query) => $query->whereNull('pdf_generated_at'));
}
private function supportsPdfGeneratedAt(): bool
{
return Schema::hasColumn('legacy_invoices', 'pdf_generated_at');
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<flux:heading size="lg">{{ __('Legacy Rechnungen') }}</flux:heading>
<flux:subheading>
{{ __('Legacy-Rechnungsarchiv mit read-only Übersicht, Filtern und PDF-Download. Der neue Stripe-Rechnungslauf folgt separat in Phase 8.') }}
</flux:subheading>
</div>
<flux:badge color="zinc" icon="archive-box" size="lg">
{{ __('Legacy-Archiv') }}
</flux:badge>
</div>
</flux:card>
@if($stats['unmapped_count'] > 0)
<flux:callout color="yellow" icon="exclamation-triangle">
{{ __(':count Legacy-Rechnungen konnten keinem neuen User zugeordnet werden. Sie bleiben im Archiv sichtbar und sollten im Rehearsal-Report fachlich geprüft werden.', ['count' => number_format($stats['unmapped_count'], 0, ',', '.')]) }}
</flux:callout>
@endif
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Rechnungen') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['count'], 0, ',', '.') }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Archivsumme') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['total_cents'] / 100, 2, ',', '.') }} </flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Bezahlt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['paid_count'], 0, ',', '.') }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Ohne User') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['unmapped_count'], 0, ',', '.') }}</flux:text>
</flux:card>
@if($supportsPdfGeneratedAt)
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('PDF erzeugt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['generated_pdf_count'], 0, ',', '.') }}</flux:text>
</flux:card>
@else
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('PDF-Status') }}</flux:text>
<flux:text size="xl" weight="bold">{{ __('Migration offen') }}</flux:text>
</flux:card>
@endif
</div>
<flux:card>
<div class="grid gap-3 lg:grid-cols-6">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Rechnungsnr., Legacy-ID, User oder E-Mail suchen...') }}"
icon="magnifying-glass"
class="lg:col-span-2"
/>
<flux:select wire:model.live="portalFilter">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach($portalOptions as $portal)
<option value="{{ $portal->value }}">{{ $portal->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="statusFilter">
<option value="all">{{ __('Alle Status') }}</option>
@foreach($statusOptions as $status)
<option value="{{ $status }}">{{ $status }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="mappingFilter">
<option value="all">{{ __('Alle Zuordnungen') }}</option>
<option value="mapped">{{ __('Mit User') }}</option>
<option value="unmapped">{{ __('Ohne User') }}</option>
</flux:select>
<flux:select wire:model.live="pdfFilter">
<option value="all">{{ __('Alle PDFs') }}</option>
@if($supportsPdfGeneratedAt)
<option value="generated">{{ __('PDF erzeugt') }}</option>
<option value="pending">{{ __('Noch nicht erzeugt') }}</option>
@endif
</flux:select>
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<flux:text class="text-sm text-zinc-500">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</flux:text>
<flux:button size="sm" variant="ghost" icon="arrow-path" wire:click="resetFilters">
{{ __('Filter zurücksetzen') }}
</flux:button>
</div>
</flux:card>
<flux:card class="p-0">
<div class="p-4">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Betrag') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('PDF') }}</flux:table.column>
</flux:table.columns>
@forelse($invoices as $invoice)
<flux:table.row wire:key="admin-legacy-invoice-{{ $invoice->id }}">
<flux:table.cell>
<div class="space-y-1">
<flux:text weight="semibold">{{ $invoice->number ?? ('#'.$invoice->legacy_id) }}</flux:text>
<flux:text class="text-xs text-zinc-500">Legacy-ID: {{ $invoice->legacy_id }}</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="zinc">{{ $invoice->legacy_portal?->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if($invoice->user)
<div class="space-y-1">
<flux:button
size="xs"
variant="ghost"
:href="route('admin.users.show', $invoice->user)"
wire:navigate
>
{{ $invoice->user->name }}
</flux:button>
<flux:text class="text-xs text-zinc-500">{{ $invoice->user->email }}</flux:text>
</div>
@else
<div class="space-y-1">
<flux:badge size="sm" color="yellow">{{ __('Ohne Zuordnung') }}</flux:badge>
<flux:text class="text-xs text-zinc-500">Legacy-User: {{ $invoice->legacy_user_id ?? 'n/a' }}</flux:text>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:text weight="semibold">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} </flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="{{ $invoice->paid_at ? 'green' : 'yellow' }}">
{{ $invoice->status ?? ($invoice->paid_at ? __('Bezahlt') : __('Offen')) }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-1">
<flux:text class="text-sm text-zinc-500">{{ $invoice->invoice_date?->format('d.m.Y') ?? '' }}</flux:text>
@if($invoice->paid_at)
<flux:text class="text-xs text-zinc-500">{{ __('bezahlt: :date', ['date' => $invoice->paid_at->format('d.m.Y')]) }}</flux:text>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-2">
<flux:button
size="sm"
variant="ghost"
icon="arrow-top-right-on-square"
:href="route('admin.legacy-invoices.pdf', $invoice)"
target="_blank"
>
{{ __('Öffnen') }}
</flux:button>
@if($supportsPdfGeneratedAt && $invoice->pdf_generated_at)
<flux:badge size="sm" color="green">{{ __('erzeugt') }}</flux:badge>
@endif
</div>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center py-10">
<flux:icon.document-text class="size-10 text-zinc-300" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Legacy-Rechnungen für diese Filter gefunden.') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</div>
</flux:card>
{{ $invoices->links() }}
</div>

View file

@ -0,0 +1,171 @@
<?php
use App\Models\NewsletterSubscription;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Newsletter\NewsletterSyncService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends Component
{
public ?string $syncMessage = null;
public ?string $dryRunMessage = null;
public function triggerDryRun(): void
{
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
$this->dryRunMessage = 'Dry-Run: Kein Datensatz fuer Vorschau vorhanden.';
return;
}
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
? 'subscribe'
: 'unsubscribe';
$this->dryRunMessage = "Dry-Run: Es wuerde {$action} fuer Subscription #{$subscription->id} ({$subscription->email}) ausgefuehrt.";
}
public function triggerTestSync(): void
{
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
$this->syncMessage = 'Kein Datensatz fuer Test-Sync vorhanden.';
return;
}
app(NewsletterSyncService::class)->syncSubscription($subscription);
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
? 'subscribe'
: 'unsubscribe';
$this->syncMessage = "Test-Sync ausgefuehrt ({$action}) fuer Subscription #{$subscription->id}.";
}
public function with(): array
{
return [
'stats' => $this->stats(),
'syncConfig' => [
'enabled' => (bool) config('newsletter.sync.enabled'),
'provider' => (string) config('newsletter.sync.provider'),
'endpoint' => (string) (config('newsletter.sync.endpoint') ?? '-'),
'timeout' => (int) config('newsletter.sync.timeout', 10),
],
];
}
/**
* @return array{total: int, confirmed: int, pending: int, unsubscribed: int}
*/
private function stats(): array
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::NewsletterStats, AdminPerformanceCache::StatsTtl, function (): array {
$stats = NewsletterSubscription::query()
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as confirmed', [true])
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as pending', [false])
->selectRaw('SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed')
->first();
return [
'total' => (int) ($stats->total ?? 0),
'confirmed' => (int) ($stats->confirmed ?? 0),
'pending' => (int) ($stats->pending ?? 0),
'unsubscribed' => (int) ($stats->unsubscribed ?? 0),
];
});
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<flux:heading size="lg">{{ __('Newsletter Synchronisierung') }}</flux:heading>
<flux:text class="text-zinc-500 dark:text-zinc-400">
{{ __('Vorbereitung fuer die kuenftige externe API-Anbindung. Aktuell ist nur das technische Grundgeruest aktiv.') }}
</flux:text>
</div>
<div class="flex flex-col items-end gap-2">
@if ($syncConfig['enabled'])
<flux:badge color="green" icon="check" size="sm">{{ __('Sync aktiv') }}</flux:badge>
@else
<flux:badge color="zinc" icon="pause" size="sm">{{ __('Sync deaktiviert') }}</flux:badge>
@endif
<div class="flex gap-2">
<flux:button size="sm" variant="ghost" icon="eye" wire:click="triggerDryRun">
{{ __('Dry Run') }}
</flux:button>
<flux:button size="sm" icon="play" wire:click="triggerTestSync">
{{ __('Test-Sync ausfuehren') }}
</flux:button>
</div>
</div>
</div>
</flux:card>
@if ($dryRunMessage)
<flux:card>
<flux:text class="text-sm text-zinc-600 dark:text-zinc-300">{{ $dryRunMessage }}</flux:text>
</flux:card>
@endif
@if ($syncMessage)
<flux:card>
<flux:text class="text-sm">{{ $syncMessage }}</flux:text>
</flux:card>
@endif
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Bestaetigt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['confirmed'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Unbestaetigt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['pending'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Abgemeldet') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['unsubscribed'] }}</flux:text>
</flux:card>
</div>
<flux:card>
<flux:heading size="sm">{{ __('Konfiguration') }}</flux:heading>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<flux:text class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">{{ __('Provider') }}</flux:text>
<flux:text class="mt-1">{{ $syncConfig['provider'] }}</flux:text>
</div>
<div>
<flux:text class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">{{ __('Timeout') }}</flux:text>
<flux:text class="mt-1">{{ $syncConfig['timeout'] }}s</flux:text>
</div>
<div class="sm:col-span-2">
<flux:text class="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">{{ __('Endpoint') }}</flux:text>
<flux:text class="mt-1 break-all">{{ $syncConfig['endpoint'] }}</flux:text>
</div>
</div>
</flux:card>
</div>

View file

@ -0,0 +1,53 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Component
{
public function with(): array
{
return [];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<flux:heading size="lg">{{ __('Zahlungen') }}</flux:heading>
<flux:subheading>
{{ __('Zahlungsabwicklung läuft in Phase 8 ausschließlich über Stripe alte Zahlungsarten (Rechnung, PayPal, SPK Berlin, Cortal Consors, Bar/Post) entfallen komplett.') }}
</flux:subheading>
</div>
<flux:badge color="amber" icon="clock" size="lg">
{{ __('In Vorbereitung') }}
</flux:badge>
</div>
</flux:card>
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Geplant für P8') }}</flux:heading>
<ul class="space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
<li class="flex gap-2">
<flux:icon.check-circle class="size-5 shrink-0 text-zinc-400" />
<span>{{ __('Live-Anzeige aller Stripe-Zahlungen mit Filtern nach Status, Methode und Zeitraum.') }}</span>
</li>
<li class="flex gap-2">
<flux:icon.check-circle class="size-5 shrink-0 text-zinc-400" />
<span>{{ __('Detail-Ansicht mit Stripe-Transaktions-ID, Webhook-Trail und zugeordneter Rechnung.') }}</span>
</li>
<li class="flex gap-2">
<flux:icon.check-circle class="size-5 shrink-0 text-zinc-400" />
<span>{{ __('Refund-Workflow direkt aus dem Admin (sofern Stripe-Berechtigung gegeben).') }}</span>
</li>
</ul>
<flux:separator class="my-5" />
<flux:text class="text-sm text-zinc-500">
{{ __('Datenmodell (user_payments, user_payment_options) ist bereits angelegt; die Anbindung folgt mit Stripe-Webhooks.') }}
</flux:text>
</flux:card>
</div>

View file

@ -0,0 +1,68 @@
<?php
use App\Enums\Portal;
use Livewire\Volt\Component;
new class extends Component
{
public string $activePortal = '';
public function mount(): void
{
$this->activePortal = session('admin_portal_filter', '');
}
public function switchPortal(string $portal): void
{
if ($portal === '') {
session()->forget('admin_portal_filter');
} else {
$valid = Portal::tryFrom($portal);
if ($valid === null) {
return;
}
session(['admin_portal_filter' => $valid->value]);
}
$this->activePortal = $portal;
$this->redirect($this->redirectTarget(), navigate: false);
}
public function with(): array
{
return [
'portals' => Portal::cases(),
];
}
private function redirectTarget(): string
{
return (string) request()->headers->get('referer', route('dashboard'));
}
}; ?>
<div class="px-1 py-2">
<div class="mb-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider px-2">
{{ __('Portal-Filter') }}
</div>
<div class="flex flex-col gap-1">
<button
wire:click="switchPortal('')"
class="flex items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors {{ $activePortal === '' ? 'bg-zinc-200 dark:bg-zinc-700 font-medium' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800' }}"
>
<span class="h-2 w-2 rounded-full bg-zinc-400"></span>
{{ __('Alle Portale') }}
</button>
@foreach($portals as $portal)
@if($portal !== \App\Enums\Portal::Both)
<button
wire:click="switchPortal('{{ $portal->value }}')"
class="flex items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors {{ $activePortal === $portal->value ? 'bg-zinc-200 dark:bg-zinc-700 font-medium' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800' }}"
>
<span class="h-2 w-2 rounded-full {{ $portal === \App\Enums\Portal::Presseecho ? 'bg-green-500' : 'bg-red-500' }}"></span>
{{ $portal->label() }}
</button>
@endif
@endforeach
</div>
</div>

View file

@ -0,0 +1,78 @@
<?php
use App\Models\AdminPreset;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class extends Component
{
public string $key = '';
public string $area = 'press_releases';
public string $type = 'text';
public string $label = '';
public string $value = '';
public string $payload = '';
public bool $isActive = true;
public function save(): void
{
$validated = $this->validate([
'key' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9_.-]+$/', Rule::unique('admin_presets', 'key')],
'area' => ['required', 'string', 'max:100'],
'type' => ['required', Rule::in(['text', 'number', 'boolean', 'json'])],
'label' => ['required', 'string', 'max:255'],
'value' => ['nullable', 'string'],
'payload' => ['nullable', 'json'],
'isActive' => ['boolean'],
]);
AdminPreset::query()->create([
'key' => $validated['key'],
'area' => $validated['area'],
'type' => $validated['type'],
'label' => $validated['label'],
'value' => $validated['value'] ?: null,
'payload' => filled($validated['payload']) ? json_decode($validated['payload'], true) : null,
'is_active' => $validated['isActive'],
]);
session()->flash('success', __('Voreinstellung wurde angelegt.'));
$this->redirect(route('admin.presets.index'), navigate: true);
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="lg">{{ __('Neue Voreinstellung') }}</flux:heading>
<flux:subheading>{{ __('Texte, Zahlen oder JSON-Werte zentral fuer Admin-Funktionen pflegen.') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
@include('livewire.admin.presets.partials.form-fields')
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.presets.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Voreinstellung erstellen') }}
</flux:button>
</div>
</flux:card>
</form>

View file

@ -0,0 +1,99 @@
<?php
use App\Models\AdminPreset;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $key = '';
public string $area = '';
public string $type = 'text';
public string $label = '';
public string $value = '';
public string $payload = '';
public bool $isActive = true;
public function mount(int $id): void
{
$this->id = $id;
$preset = AdminPreset::query()->findOrFail($id);
$this->key = $preset->key;
$this->area = $preset->area;
$this->type = $preset->type;
$this->label = $preset->label;
$this->value = $preset->value ?? '';
$this->payload = $preset->payload ? json_encode($preset->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '';
$this->isActive = $preset->is_active;
}
public function save(): void
{
$validated = $this->validate([
'key' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9_.-]+$/', Rule::unique('admin_presets', 'key')->ignore($this->id)],
'area' => ['required', 'string', 'max:100'],
'type' => ['required', Rule::in(['text', 'number', 'boolean', 'json'])],
'label' => ['required', 'string', 'max:255'],
'value' => ['nullable', 'string'],
'payload' => ['nullable', 'json'],
'isActive' => ['boolean'],
]);
AdminPreset::query()
->findOrFail($this->id)
->update([
'key' => $validated['key'],
'area' => $validated['area'],
'type' => $validated['type'],
'label' => $validated['label'],
'value' => $validated['value'] ?: null,
'payload' => filled($validated['payload']) ? json_decode($validated['payload'], true) : null,
'is_active' => $validated['isActive'],
]);
session()->flash('success', __('Voreinstellung wurde gespeichert.'));
$this->redirect(route('admin.presets.index'), navigate: true);
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="lg">{{ __('Voreinstellung bearbeiten') }}</flux:heading>
<flux:subheading>{{ __('ID') }}: {{ $id }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
@include('livewire.admin.presets.partials.form-fields')
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.presets.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Änderungen speichern') }}
</flux:button>
</div>
</flux:card>
</form>

View file

@ -0,0 +1,184 @@
<?php
use App\Models\AdminPreset;
use App\Services\Admin\AdminPerformanceCache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $areaFilter = 'all';
public string $typeFilter = 'all';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedAreaFilter(): void
{
$this->resetPage();
}
public function updatedTypeFilter(): void
{
$this->resetPage();
}
public function with(): array
{
$presets = AdminPreset::query()
->when(filled($this->search), function ($query): void {
$term = $this->search;
$query->where(function ($query) use ($term): void {
$query->where('key', 'like', '%'.$term.'%')
->orWhere('label', 'like', '%'.$term.'%')
->orWhere('value', 'like', '%'.$term.'%');
});
})
->when($this->areaFilter !== 'all', fn ($query) => $query->where('area', $this->areaFilter))
->when($this->typeFilter !== 'all', fn ($query) => $query->where('type', $this->typeFilter))
->orderBy('area')
->orderBy('key')
->paginate(50);
return [
'presets' => $presets,
'areas' => $this->areas(),
'types' => $this->types(),
];
}
private function areas()
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PresetAreas, AdminPerformanceCache::OptionsTtl, fn () => AdminPreset::query()
->select('area')
->distinct()
->orderBy('area')
->pluck('area'));
}
private function types()
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PresetTypes, AdminPerformanceCache::OptionsTtl, fn () => AdminPreset::query()
->select('type')
->distinct()
->orderBy('type')
->pluck('type'));
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Voreinstellungen') }}</flux:heading>
<flux:subheading>{{ __('Zentrale Admin-Presets fuer Texte, Zahlen und weitere Werte.') }}</flux:subheading>
</div>
<flux:button icon="plus" variant="primary" href="{{ route('admin.presets.create') }}" wire:navigate>
{{ __('Neue Voreinstellung') }}
</flux:button>
</div>
</flux:card>
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Key, Bezeichnung oder Wert suchen...') }}"
icon="magnifying-glass"
class="flex-1"
/>
<flux:select wire:model.live="areaFilter" class="sm:w-48">
<option value="all">{{ __('Alle Bereiche') }}</option>
@foreach($areas as $area)
<option value="{{ $area }}">{{ $area }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="typeFilter" class="sm:w-40">
<option value="all">{{ __('Alle Typen') }}</option>
@foreach($types as $type)
<option value="{{ $type }}">{{ $type }}</option>
@endforeach
</flux:select>
</div>
</flux:card>
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Key') }}</flux:table.column>
<flux:table.column>{{ __('Bereich') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Wert') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($presets as $preset)
<flux:table.row wire:key="{{ $preset->id }}">
<flux:table.cell>
<div class="max-w-xs">
<flux:text weight="semibold" class="truncate">{{ $preset->label }}</flux:text>
<flux:text class="truncate text-xs text-zinc-500">{{ $preset->key }}</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="blue" size="sm">{{ $preset->area }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="zinc" size="sm">{{ $preset->type }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="line-clamp-2 max-w-sm text-sm text-zinc-600 dark:text-zinc-300">
{{ \Illuminate\Support\Str::limit($preset->value ?? '-', 140) }}
</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ $preset->is_active ? 'green' : 'zinc' }}" size="sm">
{{ $preset->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.presets.edit', $preset->id) }}" wire:navigate />
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center py-10">
<flux:icon.cog class="size-10 text-zinc-400" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Voreinstellungen gefunden.') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</flux:card>
{{ $presets->links() }}
</div>

View file

@ -0,0 +1,73 @@
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basisdaten') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Key') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="key" placeholder="press_releases.deleted_published_text" />
<flux:description>{{ __('Technischer Schlüssel. Erlaubt sind Kleinbuchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.') }}</flux:description>
<flux:error name="key" />
</flux:field>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Bereich') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="area" placeholder="press_releases" />
<flux:error name="area" />
</flux:field>
<flux:field>
<flux:label>{{ __('Typ') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="type">
<option value="text">{{ __('Text') }}</option>
<option value="number">{{ __('Zahl') }}</option>
<option value="boolean">{{ __('Boolean') }}</option>
<option value="json">{{ __('JSON') }}</option>
</flux:select>
<flux:error name="type" />
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Bezeichnung') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="label" placeholder="{{ __('Ersatztext für gelöschte veröffentlichte Pressemitteilungen') }}" />
<flux:error name="label" />
</flux:field>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Werte') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Wert') }}</flux:label>
<flux:textarea wire:model="value" rows="10" />
<flux:description>{{ __('Hauptwert des Presets. Für Texte ist dies der eigentliche Inhalt.') }}</flux:description>
<flux:error name="value" />
</flux:field>
<flux:field>
<flux:label>{{ __('Payload JSON') }}</flux:label>
<flux:textarea wire:model="payload" rows="8" placeholder='{"example": true}' />
<flux:description>{{ __('Optionaler strukturierter Zusatzwert. Leer lassen, wenn nicht benötigt.') }}</flux:description>
<flux:error name="payload" />
</flux:field>
</div>
</flux:card>
</div>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Status') }}</flux:heading>
<flux:checkbox wire:model="isActive" label="{{ __('Aktiv') }}" />
<flux:text class="mt-3 text-sm text-zinc-500">
{{ __('Nur aktive Presets werden von der Anwendung automatisch verwendet.') }}
</flux:text>
</flux:card>
</div>
</div>

View file

@ -0,0 +1,290 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
{
public string $portal = 'presseecho';
public string $language = 'de';
public int|string|null $companyId = null;
public string $companySearch = '';
public int|string|null $categoryId = null;
public string $title = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public bool $noExport = false;
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
public function updatedTitle(): void
{
$this->resetErrorBag('title');
}
public function save(string $submitStatus = 'draft'): void
{
$this->validate([
'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))],
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'title' => ['required', 'string', 'min:5', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
]);
$status = match ($submitStatus) {
'review' => PressReleaseStatus::Review,
default => PressReleaseStatus::Draft,
};
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
$pr = PressRelease::query()->create([
'uuid' => (string) Str::uuid(),
'portal' => $this->portal,
'language' => $this->language,
'user_id' => auth()->id(),
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'slug' => $slug,
'text' => $this->text,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'status' => $status->value,
'no_export' => $this->noExport,
]);
session()->flash('success', $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Pressemitteilung als Entwurf gespeichert.'));
$this->redirect(route('admin.press-releases.edit', $pr->id), navigate: true);
}
public function with(): array
{
$term = trim($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
})
->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId]))
->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1'))
->orderBy('name')
->limit(50)
->get(['id', 'name']);
return [
'companies' => $companies,
'categories' => $this->categoryOptions(),
'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both),
];
}
private function categoryOptions(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get());
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:heading size="lg">{{ __('Neue Pressemitteilung') }}</flux:heading>
<flux:subheading>{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
{{-- Hauptinhalt --}}
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model.live.debounce.500ms="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}" />
<flux:error name="text" />
</flux:field>
</div>
</flux:card>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennte Stichwörter…') }}" />
<flux:error name="keywords" />
</flux:field>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</div>
{{-- Sidebar --}}
<div class="space-y-4">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Firma') }} <span class="text-red-500">*</span></flux:label>
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
</div>
</flux:card>
<flux:card>
<div class="space-y-2">
<flux:button
type="button"
variant="primary"
class="w-full"
wire:click="save('review')"
wire:loading.attr="disabled"
>
{{ __('Zur Prüfung einreichen') }}
</flux:button>
<flux:button
type="button"
variant="ghost"
class="w-full"
wire:click="save('draft')"
wire:loading.attr="disabled"
>
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</flux:card>
</div>
</div>
</div>

View file

@ -0,0 +1,480 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $portal = '';
public string $language = 'de';
public int|string|null $companyId = null;
public string $companySearch = '';
public int|string|null $categoryId = null;
public string $title = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public bool $noExport = false;
public string $currentStatus = '';
public string $targetStatus = '';
public function mount(int $id): void
{
$this->id = $id;
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
$this->portal = $pr->portal->value;
$this->language = $pr->language;
$this->companyId = $pr->company_id;
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->text = $pr->text;
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
$this->noExport = $pr->no_export;
$this->currentStatus = $pr->status->value;
$this->targetStatus = $this->currentStatus;
}
public function updatedCompanySearch(): void
{
$this->resetErrorBag('companyId');
}
public function save(): void
{
$this->validate([
'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))],
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'title' => ['required', 'string', 'min:5', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
]);
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
if ($pr->title !== $this->title || $pr->portal !== $this->portal || $pr->language !== $this->language) {
$slug = $pr->generateUniqueSlug($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
} else {
$slug = $pr->slug;
}
$pr->update([
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'slug' => $slug,
'text' => $this->text,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'no_export' => $this->noExport,
]);
session()->flash('success', __('Pressemitteilung gespeichert.'));
}
public function submitForReview(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
$this->currentStatus = PressReleaseStatus::Rejected->value;
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
return;
}
$this->currentStatus = PressReleaseStatus::Review->value;
session()->flash('success', __('Zur Prüfung eingereicht.'));
}
public function publish(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
$this->currentStatus = PressReleaseStatus::Rejected->value;
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
return;
}
$this->currentStatus = PressReleaseStatus::Published->value;
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
}
public function reject(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->reject($pr);
$this->currentStatus = PressReleaseStatus::Rejected->value;
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
}
public function backToDraft(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->backToDraft($pr);
$this->currentStatus = PressReleaseStatus::Draft->value;
session()->flash('success', __('Zurück auf Entwurf gesetzt.'));
}
public function archive(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->archive($pr);
$this->currentStatus = PressReleaseStatus::Archived->value;
$this->targetStatus = $this->currentStatus;
session()->flash('success', __('Pressemitteilung archiviert.'));
}
public function changeStatus(): void
{
$this->validate([
'targetStatus' => ['required', Rule::in(array_map(fn (PressReleaseStatus $status) => $status->value, PressReleaseStatus::cases()))],
]);
if ($this->targetStatus === $this->currentStatus) {
$this->addError('targetStatus', __('Bitte wähle einen anderen Status aus.'));
return;
}
$status = PressReleaseStatus::from($this->targetStatus);
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->changeStatusFromAdmin($pr, $status);
$this->currentStatus = $status->value;
$this->targetStatus = $status->value;
session()->flash('success', __('Status wurde auf ":status" geändert.', ['status' => $status->label()]));
Flux::modal('confirm-status-change')->close();
}
public function deletePressRelease(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
$wasPublished = $pr->status === PressReleaseStatus::Published;
app(PressReleaseService::class)->deleteFromAdmin($pr);
session()->flash('success', $wasPublished
? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.')
: __('Pressemitteilung wurde gelöscht.'));
$this->redirect(route('admin.press-releases.index'), navigate: true);
}
public function with(): array
{
$term = trim($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$q->where('name', 'like', '%'.$term.'%')->orWhere('slug', 'like', '%'.$term.'%');
})
->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId]))
->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1'))
->orderBy('name')
->limit(50)
->get(['id', 'name']);
$statusEnum = PressReleaseStatus::tryFrom($this->currentStatus);
return [
'companies' => $companies,
'categories' => $this->categoryOptions(),
'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both),
'statusOptions' => PressReleaseStatus::cases(),
'statusEnum' => $statusEnum,
'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus),
'statusColor' => match ($this->currentStatus) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
},
];
}
private function categoryOptions(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get());
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
<flux:subheading>ID: {{ $id }}</flux:subheading>
</div>
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor" size="lg">{{ $statusEnum?->label() ?? $currentStatus }}</flux:badge>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</div>
</flux:card>
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
{{-- Hauptinhalt --}}
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="title" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:error name="text" />
</flux:field>
</div>
</flux:card>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
</flux:field>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
</div>
{{-- Sidebar --}}
<div class="space-y-4">
{{-- Status-Aktionen --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Status-Aktionen') }}</flux:heading>
<div class="space-y-3">
<flux:field>
<flux:label>{{ __('Neuer Status') }}</flux:label>
<flux:select wire:model.live="targetStatus">
@foreach($statusOptions as $statusOption)
<option value="{{ $statusOption->value }}">
{{ $statusOption->label() }}{{ $statusOption->value === $currentStatus ? ' (aktuell)' : '' }}
</option>
@endforeach
</flux:select>
<flux:error name="targetStatus" />
</flux:field>
<flux:modal.trigger name="confirm-status-change">
<flux:button type="button" variant="primary" class="w-full">
{{ __('Status wechseln') }}
</flux:button>
</flux:modal.trigger>
</div>
</flux:card>
{{-- Metadaten --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Firma') }}</flux:label>
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }}</flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
</div>
</flux:card>
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Änderungen speichern') }}
</flux:button>
<flux:modal.trigger name="confirm-delete-press-release">
<flux:button type="button" variant="danger" icon="trash" class="w-full">
{{ __('Pressemitteilung löschen') }}
</flux:button>
</flux:modal.trigger>
</div>
</div>
<flux:modal name="confirm-status-change" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Status wirklich wechseln?') }}</flux:heading>
<flux:subheading>
{{ __('Aktuell: :current. Neuer Status: :target.', [
'current' => $statusEnum?->label() ?? $currentStatus,
'target' => $targetStatusEnum?->label() ?? $targetStatus,
]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="changeStatus">{{ __('Status ändern') }}</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-delete-press-release" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung löschen?') }}</flux:heading>
<flux:subheading>
@if($currentStatus === 'published')
{{ __('Diese Pressemitteilung ist veröffentlicht. Sie wird nicht entfernt, sondern archiviert und der Inhalt wird durch den voreingestellten Ersatztext ersetzt, damit die URL keinen 404-Fehler erzeugt.') }}
@else
{{ __('Diese Pressemitteilung wird per Soft Delete aus den Standardlisten entfernt.') }}
@endif
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deletePressRelease">{{ __('Löschung bestätigen') }}</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,719 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class extends Component
{
use WithPagination;
public string $search = '';
#[Url(as: 'status', except: 'all')]
public string $statusFilter = 'all';
public string $portalFilter = 'all';
public string $languageFilter = 'all';
#[Url(as: 'category', except: 'all')]
public string $categoryFilter = 'all';
#[Url(as: 'user', except: 'all')]
public string $userFilter = 'all';
#[Url(as: 'company', except: 'all')]
public string $companyFilter = 'all';
#[Url(as: 'contact', except: 'all')]
public string $contactFilter = 'all';
public string $userLookup = '';
public string $companyLookup = '';
public string $contactLookup = '';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
}
public function updatedLanguageFilter(): void
{
$this->resetPage();
}
public function updatedCategoryFilter(): void
{
$this->resetPage();
}
public function updatedUserFilter(): void
{
$this->resetPage();
}
public function updatedCompanyFilter(): void
{
$this->resetPage();
}
public function updatedContactFilter(): void
{
$this->resetPage();
}
public function clearUserFilter(): void
{
$this->userFilter = 'all';
$this->userLookup = '';
$this->resetPage();
}
public function clearCompanyFilter(): void
{
$this->companyFilter = 'all';
$this->companyLookup = '';
$this->resetPage();
}
public function clearContactFilter(): void
{
$this->contactFilter = 'all';
$this->contactLookup = '';
$this->resetPage();
}
public function resetEntityFilters(): void
{
$this->clearUserFilter();
$this->clearCompanyFilter();
$this->clearContactFilter();
}
public function publish(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
return;
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
}
public function reject(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung abgelehnt.'));
}
public function archive(int $id): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', __('Pressemitteilung archiviert.'));
}
public function with(): array
{
$query = PressRelease::withoutGlobalScopes()
->with(['company:id,name', 'category.translations', 'user:id,name'])
->when(filled($this->search), function ($q): void {
$term = trim($this->search);
$q->where(function ($q) use ($term): void {
if ($this->supportsFullTextSearch($term)) {
$q->whereFullText(['title', 'keywords'], $term)
->orWhereHas('company', fn ($q) => $q->whereFullText(['name', 'email', 'slug'], $term));
return;
}
$q->where('title', 'like', '%'.$term.'%')
->orWhere('keywords', 'like', '%'.$term.'%')
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
});
})
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
->when($this->categoryFilter !== 'all', fn ($q) => $q->where('category_id', (int) $this->categoryFilter))
->when($this->userFilter !== 'all', fn ($q) => $q->where('user_id', (int) $this->userFilter))
->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);
return [
'pressReleases' => $query,
'stats' => $this->pressReleaseStats(),
'statusOptions' => PressReleaseStatus::cases(),
'portalOptions' => Portal::cases(),
'categoryOptions' => $this->categoryOptions(),
'userLookupResults' => $this->userLookupResults(),
'companyLookupResults' => $this->companyLookupResults(),
'contactLookupResults' => $this->contactLookupResults(),
];
}
/**
* @return array{total: int, published: int, review: int, draft: int}
*/
private function pressReleaseStats(): array
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseStats, AdminPerformanceCache::StatsTtl, function (): array {
$stats = PressRelease::withoutGlobalScopes()
->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])
->first();
return [
'total' => (int) ($stats->total ?? 0),
'published' => (int) ($stats->published ?? 0),
'review' => (int) ($stats->review ?? 0),
'draft' => (int) ($stats->draft ?? 0),
];
});
}
private function categoryOptions()
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->select(['id', 'is_active'])
->with(['translations:id,category_id,locale,name,slug'])
->where('is_active', true)
->orderBy('id')
->get());
}
private function userLookupResults()
{
$term = trim($this->userLookup);
if ($term === '' && $this->userFilter === 'all') {
return collect();
}
return User::query()
->select(['id', 'name', 'email'])
->where(function ($query) use ($term): void {
if ($this->userFilter !== 'all') {
$query->where('id', (int) $this->userFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('name')
->limit(20)
->get();
}
private function companyLookupResults()
{
$term = trim($this->companyLookup);
if ($term === '' && $this->companyFilter === 'all') {
return collect();
}
return Company::withoutGlobalScopes()
->select(['id', 'name', 'slug', 'email'])
->where(function ($query) use ($term): void {
if ($this->companyFilter !== 'all') {
$query->where('id', (int) $this->companyFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('name')
->limit(20)
->get();
}
private function contactLookupResults()
{
$term = trim($this->contactLookup);
if ($term === '' && $this->contactFilter === 'all') {
return collect();
}
return Contact::withoutGlobalScopes()
->select(['id', 'company_id', 'first_name', 'last_name', 'email'])
->with('company:id,name')
->where(function ($query) use ($term): void {
if ($this->contactFilter !== 'all') {
$query->where('id', (int) $this->contactFilter);
}
if ($term !== '') {
$query->orWhere(function ($searchQuery) use ($term): void {
$searchQuery
->where('first_name', 'like', '%'.$term.'%')
->orWhere('last_name', 'like', '%'.$term.'%')
->orWhere('email', 'like', '%'.$term.'%');
});
}
})
->orderBy('last_name')
->orderBy('first_name')
->limit(20)
->get();
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-6">
@if (session('success'))
<div
class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
{{-- Statistiken --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
</flux:card>
</div>
<div class="flex justify-end">
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM') }}
</flux:button>
</div>
{{-- Filter --}}
<flux:card>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-6">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Titel, Stichwort, Firma…') }}"
icon="magnifying-glass"
class="lg:col-span-2"
/>
<flux:select wire:model.live="statusFilter" class="w-full">
<option value="all">{{ __('Alle Status') }}</option>
@foreach ($statusOptions as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="portalFilter" class="w-full">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $p)
@if ($p !== \App\Enums\Portal::Both)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endif
@endforeach
</flux:select>
<flux:select wire:model.live="languageFilter" class="w-full">
<option value="all">{{ __('Alle Sprachen') }}</option>
<option value="de">DE</option>
<option value="en">EN</option>
</flux:select>
<flux:select wire:model.live="categoryFilter" class="w-full">
<option value="all">{{ __('Alle Kategorien') }}</option>
@foreach ($categoryOptions as $categoryOption)
@php($categoryName = $categoryOption->translations->firstWhere('locale', 'de')?->name ?? '#' . $categoryOption->id)
<option value="{{ $categoryOption->id }}">{{ $categoryName }}</option>
@endforeach
</flux:select>
</div>
<div class="grid gap-3 lg:grid-cols-3">
<div class="flex gap-2">
<flux:select
wire:model.live="userFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('User suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="userLookup"
placeholder="{{ __('User suchen…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle User') }}</flux:select.option>
@foreach($userLookupResults as $userOption)
<flux:select.option :value="$userOption->id" wire:key="pm-user-{{ $userOption->id }}">
{{ $userOption->name }}
<span class="ml-1 text-zinc-400">· {{ $userOption->email }}</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($userLookup)) ? __('Zum Laden Usernamen oder E-Mail eingeben.') : __('Kein User gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearUserFilter"
title="{{ __('Usersuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="companyFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('Firma suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companyLookup"
placeholder="{{ __('Firma, Slug oder E-Mail…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle Firmen') }}</flux:select.option>
@foreach($companyLookupResults as $companyOption)
<flux:select.option :value="$companyOption->id" wire:key="pm-company-{{ $companyOption->id }}">
{{ $companyOption->name }}
@if($companyOption->email)<span class="ml-1 text-zinc-400">· {{ $companyOption->email }}</span>@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($companyLookup)) ? __('Zum Laden Firmennamen, Slug oder E-Mail eingeben.') : __('Keine Firma gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearCompanyFilter"
title="{{ __('Firmensuche zurücksetzen') }}"
/>
</div>
<div class="flex gap-2">
<flux:select
wire:model.live="contactFilter"
variant="combobox"
:filter="false"
placeholder="{{ __('Kontakt suchen…') }}"
class="min-w-0 flex-1"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="contactLookup"
placeholder="{{ __('Kontakt oder E-Mail…') }}"
/>
</x-slot>
<flux:select.option value="all">{{ __('Alle Kontakte') }}</flux:select.option>
@foreach($contactLookupResults as $contactOption)
@php($contactName = trim(($contactOption->first_name ?? '').' '.($contactOption->last_name ?? '')) ?: __('Kontakt ohne Name'))
<flux:select.option :value="$contactOption->id" wire:key="pm-contact-{{ $contactOption->id }}">
{{ $contactName }}
<span class="ml-1 text-zinc-400">
@if($contactOption->email)· {{ $contactOption->email }} @endif
· {{ $contactOption->company?->name ?? __('Unbekannte Firma') }}
</span>
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
{{ blank(trim($contactLookup)) ? __('Zum Laden Kontaktname oder E-Mail eingeben.') : __('Kein Kontakt gefunden.') }}
</flux:select.option.empty>
</x-slot>
</flux:select>
<flux:button
type="button"
size="sm"
variant="ghost"
icon="x-mark"
wire:click="clearContactFilter"
title="{{ __('Kontaktsuche zurücksetzen') }}"
/>
</div>
</div>
</div>
</flux:card>
{{-- Tabelle --}}
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
wire:click="sort('created_at')">{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'status'" :direction="$sortDir"
wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'portal'" :direction="$sortDir"
wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
wire:click="sort('hits')">
{{ __('Hits') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($pressReleases as $pr)
<flux:table.row wire:key="{{ $pr->id }}">
<flux:table.cell>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="eye"
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate />
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate />
</div>
</flux:table.cell>
<flux:table.cell>
<div class="max-w-xs">
<p class="truncate font-medium">{{ $pr->title ?? '' }}</p>
<p class="text-sm truncate text-zinc-400">
{{ $pr->company?->name ?? '' . ' | ' . strtoupper($pr->language) }}
</p>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y H:i') }}</flux:text>
</flux:table.cell>
<flux:table.cell>
@php($categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '')
<div class="max-w-48">
<flux:text class="truncate text-sm" title="{{ $categoryName }}">{{ $categoryName }}
</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge
color="{{ match ($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'draft' => 'zinc',
'rejected' => 'red',
'archived' => 'blue',
} }}">
{{ $pr->status->label() }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ $pr->portal->label() }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ number_format($pr->hits) }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1">
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="check-circle"
class="text-green-600" />
</flux:modal.trigger>
<flux:modal.trigger name="confirm-index-reject-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="x-circle" class="text-red-600" />
</flux:modal.trigger>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal.trigger name="confirm-index-archive-{{ $pr->id }}">
<flux:button size="sm" variant="ghost" icon="archive-box"
class="text-zinc-500" />
</flux:modal.trigger>
@endif
</div>
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-index-publish-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion veröffentlicht die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="publish({{ $pr->id }})">
{{ __('Veröffentlichen') }}</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-index-reject-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion lehnt die Pressemitteilung ":title" ab.', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="reject({{ $pr->id }})">
{{ __('Ablehnen') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal name="confirm-index-archive-{{ $pr->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}
</flux:heading>
<flux:subheading>
{{ __('Diese Aktion archiviert die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="archive({{ $pr->id }})">
{{ __('Archivieren') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center py-10">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Pressemitteilungen gefunden.') }}
</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</flux:card>
{{ $pressReleases->links() }}
</div>

View file

@ -0,0 +1,331 @@
<?php
use App\Models\PressRelease;
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;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
#[Locked]
public int $id;
public string $rejectReason = '';
public function mount(int $id): void
{
$this->id = $id;
}
public function publish(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
Flux::modal('confirm-show-publish')->close();
return;
}
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
Flux::modal('confirm-show-publish')->close();
}
public function reject(): void
{
$this->validate([
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
]);
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
$this->rejectReason = '';
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
Flux::modal('confirm-show-reject')->close();
}
public function archive(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
app(PressReleaseService::class)->archive($pr);
session()->flash('success', __('Archiviert.'));
Flux::modal('confirm-show-archive')->close();
}
public function with(): array
{
$pr = PressRelease::withoutGlobalScopes()
->with([
'company:id,name,slug',
'category.translations',
'user:id,name',
'images',
'statusLogs.changedBy:id,name',
])
->findOrFail($this->id);
return [
'pr' => $pr,
'statusLogs' => $pr->statusLogs,
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
?? $pr->category?->translations->first()?->name
?? '',
'statusColor' => match ($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
},
];
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ strtoupper($pr->language) }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ $pr->portal->label() }}</flux:badge>
</div>
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Firma') }}: {{ $pr->company?->name ?? '' }} ·
{{ __('Kategorie') }}: {{ $categoryName }} ·
{{ __('Autor') }}: {{ $pr->user?->name ?? '' }}
</flux:text>
</div>
<div class="flex gap-2">
<flux:button variant="ghost" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</div>
</flux:card>
{{-- Status-Aktionen --}}
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:card>
<div class="flex flex-wrap items-center gap-3">
<flux:text weight="medium" class="text-yellow-700 dark:text-yellow-400">
{{ __('Diese PM wartet auf Prüfung.') }}
</flux:text>
<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>
</flux:card>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:card>
<div class="flex items-center gap-3">
<flux:modal.trigger name="confirm-show-archive">
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
</flux:modal.trigger>
@if($pr->hits > 0)
<flux:text class="text-sm text-zinc-500">{{ number_format($pr->hits) }} {{ __('Aufrufe') }}</flux:text>
@endif
</div>
</flux:card>
@endif
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
{{-- Text --}}
<flux:card>
<div class="prose prose-zinc dark:prose-invert max-w-none">
{!! nl2br(e($pr->text)) !!}
</div>
</flux:card>
{{-- Details --}}
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Details') }}</flux:heading>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Status') }}</dt>
<dd class="font-medium">{{ $pr->status->label() }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Erstellt') }}</dt>
<dd>{{ $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-zinc-500">{{ __('Veröffentlicht') }}</dt>
<dd>{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Aufrufe') }}</dt>
<dd>{{ number_format($pr->hits) }}</dd>
</div>
@if($pr->keywords)
<div>
<dt class="text-zinc-500">{{ __('Stichwörter') }}</dt>
<dd class="mt-1">{{ $pr->keywords }}</dd>
</div>
@endif
@if($pr->backlink_url)
<div>
<dt class="text-zinc-500">{{ __('Backlink') }}</dt>
<dd class="mt-1 break-all">
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline dark:text-blue-400">
{{ $pr->backlink_url }}
</a>
</dd>
</div>
@endif
@if($pr->no_export)
<div class="rounded bg-zinc-100 px-2 py-1 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
{{ __('Kein Export') }}
</div>
@endif
</dl>
</flux:card>
@if($pr->images->isNotEmpty())
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Bilder') }}</flux:heading>
<div class="space-y-2">
@foreach($pr->images as $image)
<div class="flex items-center gap-2 text-sm">
<flux:icon.photo class="size-4 text-zinc-400" />
<span class="truncate text-zinc-600 dark:text-zinc-400">{{ basename($image->path) }}</span>
@if($image->is_preview)
<flux:badge size="sm" color="blue">{{ __('Preview') }}</flux:badge>
@endif
</div>
@endforeach
</div>
</flux:card>
@endif
</div>
</div>
@if($statusLogs->isNotEmpty())
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Status-Verlauf') }}</flux:heading>
<ol class="space-y-3 border-s border-zinc-200 ps-4 dark:border-zinc-700">
@foreach($statusLogs as $log)
<li class="text-sm">
<div class="flex flex-wrap items-center gap-2">
@php
$color = match($log->to_status?->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
};
@endphp
<flux:badge size="sm" :color="$color">{{ $log->to_status?->label() ?? $log->to_status }}</flux:badge>
@if($log->from_status)
<span class="text-xs text-zinc-500">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-xs text-zinc-500">·</span>
<span class="text-xs text-zinc-500">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if($log->changedBy)
<span class="text-xs text-zinc-500">·</span>
<span class="text-xs text-zinc-500">{{ $log->changedBy->name }}</span>
@endif
@if($log->source !== 'admin')
<flux:badge size="xs" color="zinc">{{ $log->source }}</flux:badge>
@endif
</div>
@if($log->reason)
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
</flux:card>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-show-publish" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung wird öffentlich sichtbar und der Autor wird benachrichtigt.') }}</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="publish">{{ __('Veröffentlichen') }}</flux:button>
</div>
</div>
</flux:modal>
<flux:modal name="confirm-show-reject" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung wird abgelehnt und der Autor wird benachrichtigt. Bitte begründen Sie die Ablehnung.') }}</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Begründung (an den Autor sichtbar)') }}</flux:label>
<flux:textarea
wire:model="rejectReason"
rows="5"
placeholder="{{ __('z. B. Werbliche Sprache, fehlende Belege, doppelte Veröffentlichung…') }}"
/>
<flux:error name="rejectReason" />
</flux:field>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="reject">{{ __('Ablehnen') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:modal name="confirm-show-archive" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}</flux:heading>
<flux:subheading>{{ __('Die Pressemitteilung bleibt intern erhalten, wird aber archiviert.') }}</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="archive">{{ __('Archivieren') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>

View file

@ -0,0 +1,28 @@
<flux:table>
<flux:table.columns>
<flux:table.column>{{ $label }}</flux:table.column>
<flux:table.column>{{ __('Requests') }}</flux:table.column>
<flux:table.column>{{ __('Ø Dauer') }}</flux:table.column>
<flux:table.column>{{ __('Max. Dauer') }}</flux:table.column>
<flux:table.column>{{ __('Ø DB') }}</flux:table.column>
<flux:table.column>{{ __('Queries') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($rows as $row)
<flux:table.row :key="$row['value']">
<flux:table.cell><flux:text class="font-mono text-xs">{{ $row['value'] }}</flux:text></flux:table.cell>
<flux:table.cell>{{ number_format($row['requests']) }}</flux:table.cell>
<flux:table.cell>{{ number_format($row['average_duration_ms'], 2, ',', '.') }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($row['max_duration_ms']) }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($row['average_database_time_ms'], 2, ',', '.') }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($row['total_queries']) }}</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="py-8 text-center text-sm text-zinc-500">{{ __('Keine Daten gefunden.') }}</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>

View file

@ -0,0 +1,260 @@
<?php
use App\Services\Admin\AdminSlowRequestReporter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Performance Reports')] class extends Component
{
public string $from = '';
public string $to = '';
public string $routeFilter = '';
public string $pathFilter = '';
public string $statusFilter = '';
public ?int $minDurationMs = null;
public int $limit = 25;
public function resetFilters(): void
{
$this->from = '';
$this->to = '';
$this->routeFilter = '';
$this->pathFilter = '';
$this->statusFilter = '';
$this->minDurationMs = null;
$this->limit = 25;
}
public function with(AdminSlowRequestReporter $reporter): array
{
return [
'report' => $reporter->report(
filters: [
'from' => $this->from !== '' ? $this->from : null,
'to' => $this->to !== '' ? $this->to : null,
'route' => $this->routeFilter !== '' ? $this->routeFilter : null,
'path' => $this->pathFilter !== '' ? $this->pathFilter : null,
'status' => $this->statusFilter !== '' ? (int) $this->statusFilter : null,
'min_duration_ms' => $this->minDurationMs,
],
top: 10,
limit: $this->limit,
),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<flux:heading size="lg">{{ __('Performance Reports') }}</flux:heading>
<flux:subheading>
{{ __('Auswertung der Slow-Admin-Request-Logs aus dem admin_slow Log-Kanal.') }}
</flux:subheading>
</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('Logdateien') }}: {{ $report['summary']['files'] }}
</div>
</div>
</flux:card>
<flux:card>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<flux:field>
<flux:label>{{ __('Von') }}</flux:label>
<flux:input type="datetime-local" wire:model.live.debounce.500ms="from" />
</flux:field>
<flux:field>
<flux:label>{{ __('Bis') }}</flux:label>
<flux:input type="datetime-local" wire:model.live.debounce.500ms="to" />
</flux:field>
<flux:field>
<flux:label>{{ __('Route') }}</flux:label>
<flux:input wire:model.live.debounce.500ms="routeFilter" placeholder="admin.users" />
</flux:field>
<flux:field>
<flux:label>{{ __('Pfad') }}</flux:label>
<flux:input wire:model.live.debounce.500ms="pathFilter" placeholder="/admin/users" />
</flux:field>
<flux:field>
<flux:label>{{ __('Status') }}</flux:label>
<flux:select wire:model.live="statusFilter">
<option value="">{{ __('Alle') }}</option>
<option value="200">200</option>
<option value="302">302</option>
<option value="403">403</option>
<option value="422">422</option>
<option value="500">500</option>
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Min. Dauer') }}</flux:label>
<flux:input type="number" min="0" wire:model.live.debounce.500ms="minDurationMs" placeholder="ms" />
</flux:field>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<flux:field class="max-w-36">
<flux:label>{{ __('Detailzeilen') }}</flux:label>
<flux:select wire:model.live="limit">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</flux:select>
</flux:field>
<flux:button variant="ghost" icon="arrow-path" wire:click="resetFilters">
{{ __('Filter zurücksetzen') }}
</flux:button>
</div>
</flux:card>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Requests') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($report['summary']['total_requests']) }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Ø Dauer') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($report['summary']['average_duration_ms'], 2, ',', '.') }} ms</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Max. Dauer') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($report['summary']['max_duration_ms']) }} ms</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Max. Queries') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($report['summary']['max_query_count']) }}</flux:text>
</flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<flux:card class="overflow-hidden">
<flux:heading size="md" class="mb-4">{{ __('Top Routen') }}</flux:heading>
@include('livewire.admin.reports.slow-requests-table', ['rows' => $report['top_routes'], 'label' => __('Route')])
</flux:card>
<flux:card class="overflow-hidden">
<flux:heading size="md" class="mb-4">{{ __('Top Pfade') }}</flux:heading>
@include('livewire.admin.reports.slow-requests-table', ['rows' => $report['top_paths'], 'label' => __('Pfad')])
</flux:card>
</div>
<flux:card class="overflow-hidden">
<flux:heading size="md" class="mb-4">{{ __('Langsamste Requests') }}</flux:heading>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Zeit') }}</flux:table.column>
<flux:table.column>{{ __('Route') }}</flux:table.column>
<flux:table.column>{{ __('Pfad') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Dauer') }}</flux:table.column>
<flux:table.column>{{ __('DB') }}</flux:table.column>
<flux:table.column>{{ __('Queries') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($report['slowest_requests'] as $entry)
<flux:table.row :key="$entry['timestamp'].'-'.$entry['route_name'].'-'.$entry['duration_ms']">
<flux:table.cell>{{ $entry['timestamp'] }}</flux:table.cell>
<flux:table.cell><flux:text class="font-mono text-xs">{{ $entry['route_name'] }}</flux:text></flux:table.cell>
<flux:table.cell><flux:text class="font-mono text-xs">{{ $entry['path'] }}</flux:text></flux:table.cell>
<flux:table.cell><flux:badge color="zinc" size="sm">{{ $entry['status_code'] }}</flux:badge></flux:table.cell>
<flux:table.cell>{{ number_format($entry['duration_ms']) }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($entry['database_time_ms'], 2, ',', '.') }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($entry['query_count']) }}</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="py-8 text-center text-sm text-zinc-500">{{ __('Keine Slow-Admin-Requests gefunden.') }}</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</flux:card>
<flux:card class="overflow-hidden">
<flux:heading size="md" class="mb-4">{{ __('Häufige Slow Queries') }}</flux:heading>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('SQL') }}</flux:table.column>
<flux:table.column>{{ __('Vorkommen') }}</flux:table.column>
<flux:table.column>{{ __('Ø Zeit') }}</flux:table.column>
<flux:table.column>{{ __('Max. Zeit') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($report['slow_queries'] as $query)
<flux:table.row :key="md5($query['sql'])">
<flux:table.cell><flux:text class="font-mono text-xs">{{ str($query['sql'])->limit(160) }}</flux:text></flux:table.cell>
<flux:table.cell>{{ number_format($query['occurrences']) }}</flux:table.cell>
<flux:table.cell>{{ number_format($query['average_time_ms'], 2, ',', '.') }} ms</flux:table.cell>
<flux:table.cell>{{ number_format($query['max_time_ms'], 2, ',', '.') }} ms</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="4">
<div class="py-8 text-center text-sm text-zinc-500">{{ __('Keine einzelnen Slow Queries im Sample gefunden.') }}</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</flux:card>
<flux:card class="overflow-hidden">
<flux:heading size="md" class="mb-4">{{ __('EXPLAIN Top Slow Queries') }}</flux:heading>
<div class="space-y-6">
@forelse($report['explain_plans'] as $explain)
<div class="space-y-3">
<flux:text class="font-mono text-xs">{{ str($explain['sql'])->limit(180) }}</flux:text>
@if($explain['error'])
<flux:badge color="amber" size="sm">{{ $explain['error'] }}</flux:badge>
@elseif($explain['plan'] === [])
<flux:text class="text-sm text-zinc-500">{{ __('Kein Explain-Plan zurückgegeben.') }}</flux:text>
@else
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
<tr>
@foreach(array_keys($explain['plan'][0]) as $column)
<th class="px-3 py-2 font-medium">{{ $column }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($explain['plan'] as $planRow)
<tr class="border-b border-zinc-100 dark:border-zinc-800">
@foreach($planRow as $value)
<td class="px-3 py-2 font-mono">{{ is_scalar($value) || $value === null ? (string) $value : json_encode($value) }}</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@empty
<div class="py-8 text-center text-sm text-zinc-500">{{ __('Keine Slow Queries für EXPLAIN vorhanden.') }}</div>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,134 @@
<?php
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Component
{
public string $name = '';
public array $permissions = [];
public string $guardName = 'web';
public function save(): void
{
$validated = $this->validate([
'name' => [
'required',
'min:3',
'max:50',
Rule::unique('roles', 'name')
->where(fn (Builder $query) => $query->where('guard_name', $this->guardName)),
],
'permissions' => ['array'],
'permissions.*' => [
'string',
Rule::exists('permissions', 'name')
->where(fn (Builder $query) => $query->where('guard_name', $this->guardName)),
],
]);
$role = Role::query()->create([
'name' => $validated['name'],
'guard_name' => $this->guardName,
]);
$role->syncPermissions($validated['permissions'] ?? []);
session()->flash('success', 'Rolle erfolgreich erstellt.');
$this->redirect(route('admin.roles.index'), navigate: true);
}
public function with(): array
{
return [
'permissionGroups' => $this->permissionGroups(),
];
}
private function permissionGroups(): Collection
{
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->permissionGroupsKey($this->guardName), AdminPerformanceCache::OptionsTtl, fn () => Permission::query()
->where('guard_name', $this->guardName)
->orderBy('name')
->get(['name'])
->groupBy(function (Permission $permission): string {
$prefix = Str::contains($permission->name, ':')
? Str::before($permission->name, ':')
: $permission->name;
return Str::headline(str_replace(['-', '_'], ' ', $prefix));
})
->map(fn ($group) => $group->values())
->sortKeys());
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="lg">{{ __('Neue Rolle') }}</flux:heading>
<flux:subheading>{{ __('Guard') }}: {{ $guardName }}</flux:subheading>
</div>
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basis-Informationen') }}</flux:heading>
<flux:field>
<flux:label>{{ __('Technischer Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" placeholder="{{ __('z.B. editor') }}" />
<flux:description>{{ __('Kleinbuchstaben, keine Leerzeichen. Wird intern verwendet.') }}</flux:description>
<flux:error name="name" />
</flux:field>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Berechtigungen') }}</flux:heading>
<div class="space-y-6">
@forelse($permissionGroups as $groupName => $permissionsInGroup)
<div>
<flux:heading size="md" class="mb-3">{{ $groupName }}</flux:heading>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach($permissionsInGroup as $permission)
<flux:checkbox
wire:model="permissions"
value="{{ $permission['name'] }}"
label="{{ $permission['name'] }}"
/>
@endforeach
</div>
</div>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Berechtigungen fuer diesen Guard vorhanden.') }}</flux:text>
@endforelse
</div>
<flux:error name="permissions" class="mt-4" />
</flux:card>
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.roles.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Rolle erstellen') }}
</flux:button>
</div>
</flux:card>
</form>

Some files were not shown because too many files have changed in this diff Show more