22-05-2026 Optimierung der User und Admin Panels
This commit is contained in:
parent
d2ba22c0cf
commit
e8c47b7553
73 changed files with 10282 additions and 1546 deletions
|
|
@ -6,6 +6,96 @@ use Livewire\Volt\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
|
||||
{
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'creditSummary' => [
|
||||
'total' => 17,
|
||||
'bonus' => 12,
|
||||
'paid' => 5,
|
||||
'auto_refill' => __('ab 10 Credits empfohlen'),
|
||||
'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'),
|
||||
],
|
||||
'currentPlan' => [
|
||||
'name' => 'Starter',
|
||||
'price' => '19 €/Mo.',
|
||||
'press_releases' => '3 PMs/Monat',
|
||||
'bonus_credits' => 12,
|
||||
],
|
||||
'creditPackages' => [
|
||||
['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null],
|
||||
['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'],
|
||||
['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'],
|
||||
['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'],
|
||||
['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'],
|
||||
],
|
||||
'serviceGroups' => [
|
||||
[
|
||||
'title' => __('Veröffentlichung'),
|
||||
'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'),
|
||||
'services' => [
|
||||
['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')],
|
||||
['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')],
|
||||
['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')],
|
||||
['name' => __('Depublizierung'), 'credits' => '19–25', 'meta' => __('abhängig vom Aufwand')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Bilder'),
|
||||
'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'),
|
||||
'services' => [
|
||||
['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')],
|
||||
['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')],
|
||||
['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')],
|
||||
['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('KI-Textservices'),
|
||||
'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'),
|
||||
'services' => [
|
||||
['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')],
|
||||
['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')],
|
||||
['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')],
|
||||
['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')],
|
||||
['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')],
|
||||
['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Distribution'),
|
||||
'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'),
|
||||
'services' => [
|
||||
['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')],
|
||||
['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')],
|
||||
['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')],
|
||||
['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')],
|
||||
['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => __('Account & Profil'),
|
||||
'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'),
|
||||
'services' => [
|
||||
['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')],
|
||||
['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')],
|
||||
['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')],
|
||||
],
|
||||
],
|
||||
],
|
||||
'placements' => [
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'],
|
||||
['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'],
|
||||
],
|
||||
'activeBookings' => [],
|
||||
'bookingHistory' => [],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
|
@ -15,56 +105,285 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Finanzen') }}</span>
|
||||
<span class="badge warn">{{ __('In Vorbereitung') }}</span>
|
||||
<span class="badge hub">{{ __('Konzeptstand Mai 2026') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchungen & Add-ons') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier werden künftig gebuchte Leistungen, Add-ons und Erweiterungen für Ihre Firmen gebündelt.') }}
|
||||
{{ __('Der Marktplatz für Credit-Pakete, KI-Services, Platzierungen und Firmen-Add-ons. Die Preise folgen dem neuen Credit-Modell: 1 Credit entspricht dem Listenwert von 1 €.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button size="sm" variant="ghost" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
|
||||
{{ __('Rechnungen') }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="primary" icon="plus" disabled>
|
||||
{{ __('Credits kaufen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }}
|
||||
{{-- ============== CREDIT-ÜBERSICHT ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Stand') }}</span>
|
||||
<span class="badge ok dot">{{ __('Auto-Refill vorbereitet') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-5 md:grid-cols-[0.8fr_1.2fr]">
|
||||
<div>
|
||||
<div class="text-[42px] font-bold tracking-[-1.2px] leading-none text-[color:var(--color-ink)]">
|
||||
{{ $creditSummary['total'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-2 mb-0">
|
||||
{{ __('verfügbare Credits') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Bonus-Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['bonus'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('monatlich verfallend') }}</div>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4 bg-[color:var(--color-bg-subtle)]">
|
||||
<div class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Gekaufte Credits') }}</div>
|
||||
<div class="text-[22px] font-semibold text-[color:var(--color-ink)]">{{ $creditSummary['paid'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('24 Monate gültig') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
<div class="flex-1">
|
||||
{{ $creditSummary['validity'] }}
|
||||
{{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Aktueller Tarif') }}</span>
|
||||
<span class="badge hub">{{ $currentPlan['name'] }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<div class="text-[28px] font-bold tracking-[-0.7px] text-[color:var(--color-ink)]">
|
||||
{{ $currentPlan['price'] }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ __('inkl. :credits Bonus-Credits und :pms', [
|
||||
'credits' => $currentPlan['bonus_credits'],
|
||||
'pms' => $currentPlan['press_releases'],
|
||||
]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] p-4">
|
||||
<div class="text-[12px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Nächster sinnvoller Schritt') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{-- ============== CREDIT-PAKETE ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credit-Pakete') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Volumenrabatt nach Paketgröße') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Paket') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Credits') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Preis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Effektiv/Credit') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Ersparnis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($creditPackages as $package)
|
||||
<flux:table.row wire:key="credit-package-{{ $package['name'] }}">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $package['name'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ number_format($package['credits'], 0, ',', '.') }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">{{ $package['price'] }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>{{ $package['rate'] }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($package['saving'])
|
||||
<span class="badge ok">{{ $package['saving'] }}</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="ghost" disabled>
|
||||
{{ __('Kaufen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table>
|
||||
</article>
|
||||
|
||||
{{-- ============== PLATZIERUNGEN ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Boost & Platzierungen') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[760px] m-0">
|
||||
{{ __('Platzierungen bleiben an Qualitätsstufen gekoppelt: Standard reicht für Kategorie-Highlights, Geprüft für Startseite/Newsletter/Social und Hochwertig für den Top-Slot.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($placements as $placement)
|
||||
<article class="panel">
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.megaphone class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-[14px] font-semibold text-[color:var(--color-ink)] m-0">
|
||||
{{ $placement['name'] }}
|
||||
</h3>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-1 mb-0">
|
||||
{{ $placement['duration'] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[22px] font-bold text-[color:var(--color-ink)]">{{ $placement['credits'] }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-[6px] border border-[color:var(--color-bg-rule)] p-3">
|
||||
<div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Mindeststufe') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $placement['tier'] }}</div>
|
||||
</div>
|
||||
<flux:tooltip content="{{ __('Interner Score-Schwellenwert: :score', ['score' => $placement['score']]) }}">
|
||||
<span class="badge hub">{{ __('Score :score', ['score' => $placement['score']]) }}</span>
|
||||
</flux:tooltip>
|
||||
</div>
|
||||
|
||||
<flux:button size="sm" variant="primary" class="w-full" disabled>
|
||||
{{ __('Buchung vorbereiten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ============== SERVICE-MARKTPLATZ ============== --}}
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<span class="section-eyebrow">{{ __('Add-on-Marktplatz') }}</span>
|
||||
<h2 class="text-[22px] font-bold tracking-[-0.4px] mt-1 mb-1 text-[color:var(--color-ink)]">
|
||||
{{ __('Buchbare Services nach Kategorie') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
@foreach ($serviceGroups as $group)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon.sparkles class="size-4 text-[color:var(--color-hub)]" />
|
||||
<span class="section-eyebrow">{{ $group['title'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-0 mb-4">
|
||||
{{ $group['description'] }}
|
||||
</p>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($group['services'] as $service)
|
||||
<div class="py-3 first:pt-0 last:pb-0 flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $service['name'] }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $service['meta'] }}</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-[15px] font-bold text-[color:var(--color-ink)]">{{ $service['credits'] }}</div>
|
||||
<div class="text-[10.5px] uppercase tracking-[0.08em] text-[color:var(--color-ink-3)]">{{ __('Credits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}}
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Firmenbezogene Add-ons') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Aktive Buchungen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('läuft aktuell') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }}
|
||||
</p>
|
||||
@forelse ($activeBookings as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.calendar-days class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Noch keine aktiven Buchungen') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Credits & Tarif') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Verlauf') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('verbrauchte Credits') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zahlungsarten') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }}
|
||||
</p>
|
||||
@forelse ($bookingHistory as $booking)
|
||||
<div>{{ $booking }}</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-bg-subtle)] border border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.clock class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Noch kein Buchungsverlauf') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,16 @@ new class extends Component
|
|||
public function with(CustomerCompanyContext $context): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$companies = $context->switcherCompaniesFor($user, $selectedCompanyId, 51);
|
||||
$visibleCompanies = $companies->take(50)->values();
|
||||
|
||||
return [
|
||||
'companies' => $context->companiesFor($user),
|
||||
'selectedCompany' => $context->selectedCompany($user),
|
||||
'companies' => $visibleCompanies,
|
||||
'hasMoreCompanies' => $companies->count() > 50,
|
||||
'selectedCompany' => $selectedCompanyId === null
|
||||
? null
|
||||
: $visibleCompanies->firstWhere('id', $selectedCompanyId),
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
];
|
||||
|
|
@ -55,6 +61,9 @@ new class extends Component
|
|||
{{ $company->name }} · {{ $context->roleLabelFor($company, $user) }}
|
||||
</option>
|
||||
@endforeach
|
||||
@if ($hasMoreCompanies)
|
||||
<option value="all" disabled>{{ __('Weitere Firmen über „Firmen" öffnen') }}</option>
|
||||
@endif
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,16 +21,28 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$selectedCompany = $context->selectedCompany($user);
|
||||
$selectedCompany = $selectedCompanyId === null
|
||||
? null
|
||||
: $context->findFor($user, $selectedCompanyId);
|
||||
|
||||
$pressReleaseQuery = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $user->id)
|
||||
->when($selectedCompanyId !== null, fn ($query) => $query->where('company_id', $selectedCompanyId));
|
||||
|
||||
$myPRs = (clone $pressReleaseQuery)
|
||||
->selectRaw('status, count(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
$now = Carbon::now();
|
||||
$currentStart = $now->copy()->startOfMonth();
|
||||
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
|
||||
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
|
||||
|
||||
$stats = (clone $pressReleaseQuery)
|
||||
->toBase()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->selectRaw('SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as current_month', [$currentStart])
|
||||
->selectRaw('SUM(CASE WHEN created_at BETWEEN ? AND ? THEN 1 ELSE 0 END) as previous_month', [$previousStart, $previousEnd])
|
||||
->first();
|
||||
|
||||
$recent = (clone $pressReleaseQuery)
|
||||
->with('company:id,name')
|
||||
|
|
@ -45,11 +57,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
'user' => $user,
|
||||
'selectedCompany' => $selectedCompany,
|
||||
'stats' => [
|
||||
'total' => (clone $pressReleaseQuery)->count(),
|
||||
'published' => $myPRs->get('published', 0),
|
||||
'review' => $myPRs->get('review', 0),
|
||||
'draft' => $myPRs->get('draft', 0),
|
||||
'deltaMonth' => $this->totalDeltaToPreviousMonth(clone $pressReleaseQuery),
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
'review' => (int) ($stats->review ?? 0),
|
||||
'draft' => (int) ($stats->draft ?? 0),
|
||||
'deltaMonth' => (int) ($stats->current_month ?? 0) - (int) ($stats->previous_month ?? 0),
|
||||
],
|
||||
'profileCompleteness' => $this->profileCompleteness($profile),
|
||||
'billingCompleteness' => $this->billingCompleteness($billingAddress),
|
||||
|
|
@ -61,7 +73,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
$pressReleaseQuery,
|
||||
),
|
||||
'recent' => $recent,
|
||||
'companies' => $context->companiesFor($user),
|
||||
'companies' => $context->latestCompaniesFor($user),
|
||||
'companiesTotal' => $context->companyCountFor($user),
|
||||
'bridgeStatus' => [
|
||||
/* Heute hardcoded — perspektivisch aus echtem Sync-Service. */
|
||||
'presseecho' => ['state' => 'connected', 'subline' => __('Archiv · Branchen-Tiefe')],
|
||||
|
|
@ -110,27 +123,6 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
return (int) round(($filled / count($fields)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht PRs im aktuellen Monat mit dem Vormonat (Differenz, Vorzeichen mit Pfeil im View).
|
||||
*/
|
||||
private function totalDeltaToPreviousMonth(Builder $pressReleaseQuery): int
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$currentStart = $now->copy()->startOfMonth();
|
||||
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
|
||||
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
|
||||
|
||||
$currentCount = (clone $pressReleaseQuery)
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousCount = (clone $pressReleaseQuery)
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
return $currentCount - $previousCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
|
||||
*/
|
||||
|
|
@ -234,7 +226,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ __('Aktive Firma:') }} <strong class="font-semibold">{{ $selectedCompany->name }}</strong>
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ route('me.profile') }}#firmen" wire:navigate
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
|
||||
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
|
||||
{{ __('Keine Firma zugeordnet') }}
|
||||
|
|
@ -448,11 +440,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<span class="section-eyebrow">{{ __('Meine Firmen') }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">
|
||||
{{ $companies->count() }} {{ __('zugeordnet') }}
|
||||
{{ $companiesTotal }} {{ __('zugeordnet') }}
|
||||
</span>
|
||||
<a href="{{ route('me.profile') }}" wire:navigate
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Profil & Firma verwalten') }} →
|
||||
{{ __('Alle Firmen anzeigen') }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -483,6 +475,15 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($companiesTotal > $companies->count())
|
||||
<div class="mt-4 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.') }}
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Zur vollständigen Firmenliste') }} →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
|
||||
<div class="relative rounded-[5px] p-5 transition-colors
|
||||
|
|
@ -511,11 +512,11 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
|
||||
<div class="eyebrow muted mb-2">{{ __('Hinweis') }}</div>
|
||||
<div class="text-[13px] leading-[1.55] m-0 text-[color:var(--color-ink-2)]">
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil prüfen') }}
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
|
|||
@endforelse
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $invoices->links() }}
|
||||
{{ $invoices->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal file
218
resources/views/livewire/customer/press-kits/create.blade.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CompanyType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $portal = '';
|
||||
|
||||
public string $type = '';
|
||||
|
||||
public string $address = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $website = '';
|
||||
|
||||
public string $countryCode = 'DE';
|
||||
|
||||
public bool $disableFooterCode = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->type = CompanyType::Company->value;
|
||||
$this->countryCode = (string) config('countries.default', 'DE');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'portal' => ['required', Rule::in([
|
||||
Portal::Presseecho->value,
|
||||
Portal::Businessportal24->value,
|
||||
Portal::Both->value,
|
||||
])],
|
||||
'type' => ['required', Rule::in([CompanyType::Company->value, CompanyType::Agency->value])],
|
||||
'address' => ['nullable', 'string', 'max:1000'],
|
||||
'email' => ['nullable', 'email', 'max:190'],
|
||||
'phone' => ['nullable', 'string', 'max:40'],
|
||||
'website' => ['nullable', 'url', 'max:190'],
|
||||
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$count = array_sum(array_map('count', $e->errors()));
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$company = new Company([
|
||||
'portal' => $validated['portal'],
|
||||
'owner_user_id' => $user->id,
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'address' => $validated['address'] ?: null,
|
||||
'country_code' => $validated['countryCode'] ?: null,
|
||||
'email' => $validated['email'] ?: null,
|
||||
'phone' => $validated['phone'] ?: null,
|
||||
'website' => $validated['website'] ?: null,
|
||||
'is_active' => true,
|
||||
'disable_footer_code' => $this->disableFooterCode,
|
||||
]);
|
||||
|
||||
$company->slug = $company->generateUniqueSlug($validated['name'], [
|
||||
'portal' => $validated['portal'],
|
||||
]);
|
||||
|
||||
$company->save();
|
||||
|
||||
$user->companies()->syncWithoutDetaching([
|
||||
$company->id => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Firma angelegt'),
|
||||
text: __('„:name" wurde angelegt und steht sofort zur Verfügung.', ['name' => $company->name]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('me.press-kits.show', $company->id), navigate: true);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'portals' => [
|
||||
Portal::Presseecho->value => Portal::Presseecho->label(),
|
||||
Portal::Businessportal24->value => Portal::Businessportal24->label(),
|
||||
Portal::Both->value => Portal::Both->label(),
|
||||
],
|
||||
'types' => [
|
||||
CompanyType::Company->value => CompanyType::Company->label(),
|
||||
CompanyType::Agency->value => CompanyType::Agency->label(),
|
||||
],
|
||||
'countries' => (array) config('countries.items', []),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Firmen · Anlegen') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Neue Firma anlegen') }}
|
||||
</h1>
|
||||
<p class="text-[12.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Lege Stammdaten und Portal-Zuordnung an. Die Firma steht sofort zur Verfügung — die redaktionelle Prüfung erfolgt erst bei der ersten Pressemitteilung.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Zurück zur Liste') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:input wire:model="name" :label="__('Firmenname')" required autofocus />
|
||||
<flux:error name="name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="portal" :label="__('Portal')" :placeholder="__('Bitte wählen…')" required>
|
||||
@foreach ($portals as $value => $label)
|
||||
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="portal" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="type" :label="__('Typ')" required>
|
||||
@foreach ($types as $value => $label)
|
||||
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="type" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:input wire:model="email" :label="__('E-Mail')" type="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:error name="phone" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:input wire:model="website" :label="__('Website')" placeholder="https://..." />
|
||||
<flux:error name="website" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:textarea wire:model="address" :label="__('Adresse')" rows="3" />
|
||||
<flux:error name="address" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:select wire:model="countryCode" :label="__('Land')">
|
||||
@foreach ($countries as $code => $countryName)
|
||||
<flux:select.option value="{{ $code }}">{{ $countryName }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="countryCode" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<flux:button variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary" icon="check">
|
||||
{{ __('Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -12,39 +19,402 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
|
||||
public string $search = '';
|
||||
|
||||
#[Url(as: 'view', except: 'all')]
|
||||
public string $savedView = 'all';
|
||||
|
||||
#[Url(as: 'portal', except: '')]
|
||||
public string $portalFilter = '';
|
||||
|
||||
#[Url(as: 'role', except: 'all')]
|
||||
public string $roleFilter = 'all';
|
||||
|
||||
#[Url(as: 'mode', except: 'cards')]
|
||||
public string $viewMode = 'cards';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setSavedView(string $view): void
|
||||
{
|
||||
$allowed = ['all', 'active', 'drafts', 'inactive', 'shared'];
|
||||
$this->savedView = in_array($view, $allowed, true) ? $view : 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setPortalFilter(string $portal): void
|
||||
{
|
||||
$allowed = ['', 'presseecho', 'businessportal24'];
|
||||
$this->portalFilter = in_array($portal, $allowed, true) ? $portal : '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setRoleFilter(string $role): void
|
||||
{
|
||||
$allowed = ['all', 'owner', 'responsible', 'member'];
|
||||
$this->roleFilter = in_array($role, $allowed, true) ? $role : 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function setViewMode(string $mode): void
|
||||
{
|
||||
$this->viewMode = $mode === 'list' ? 'list' : 'cards';
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->search = '';
|
||||
$this->savedView = 'all';
|
||||
$this->portalFilter = '';
|
||||
$this->roleFilter = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Company>
|
||||
*/
|
||||
private function baseQuery(User $user): Builder
|
||||
{
|
||||
return app(CustomerCompanyContext::class)
|
||||
->accessibleCompanyQuery($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet die "Saved View"-Logik auf eine Query an.
|
||||
*
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applySavedView(Builder $query, User $user, string $view): void
|
||||
{
|
||||
match ($view) {
|
||||
'active' => $query->where('is_active', true),
|
||||
'inactive' => $query->where('is_active', false),
|
||||
'drafts' => $query->whereRaw('1 = 0'),
|
||||
'shared' => $query->where('owner_user_id', '!=', $user->id),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applySharedFilters(Builder $query): void
|
||||
{
|
||||
if (filled($this->portalFilter)) {
|
||||
$query->where(function ($query) {
|
||||
$query->where('portal', $this->portalFilter)
|
||||
->orWhere('portal', 'both');
|
||||
});
|
||||
}
|
||||
|
||||
if (filled($this->search)) {
|
||||
$search = trim($this->search);
|
||||
$query->where(function ($query) use ($search): void {
|
||||
$query->where('name', 'like', '%'.$search.'%')
|
||||
->orWhere('email', 'like', '%'.$search.'%')
|
||||
->orWhere('address', 'like', '%'.$search.'%')
|
||||
->orWhere('slug', 'like', '%'.$search.'%');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Company> $query
|
||||
*/
|
||||
private function applyRoleFilter(Builder $query, User $user, string $role): void
|
||||
{
|
||||
if ($role === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($role === 'owner') {
|
||||
$query->where('owner_user_id', $user->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where('owner_user_id', '!=', $user->id)
|
||||
->whereHas('users', function ($query) use ($user, $role): void {
|
||||
$query->where('users.id', $user->id)
|
||||
->where('company_user.role', $role);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Counter-Werte in genau drei Queries:
|
||||
* 1) aggregiertes COUNT/SUM CASE auf companies
|
||||
* 2) COUNT auf press_releases
|
||||
* 3) COUNT auf contacts
|
||||
*
|
||||
* @return array{
|
||||
* counters: array{companies: int, active: int, press_releases: int, contacts: int},
|
||||
* saved_views: array{all: int, active: int, drafts: int, inactive: int, shared: int},
|
||||
* }
|
||||
*/
|
||||
private function buildAggregateCounts(User $user): array
|
||||
{
|
||||
$row = $this->baseQuery($user)
|
||||
->selectRaw(
|
||||
'COUNT(*) as total_companies,
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_companies,
|
||||
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as inactive_companies,
|
||||
SUM(CASE WHEN owner_user_id <> ? THEN 1 ELSE 0 END) as shared_companies',
|
||||
[$user->id]
|
||||
)
|
||||
->first();
|
||||
|
||||
$totalCompanies = (int) ($row->total_companies ?? 0);
|
||||
$activeCompanies = (int) ($row->active_companies ?? 0);
|
||||
$inactiveCompanies = (int) ($row->inactive_companies ?? 0);
|
||||
$sharedCompanies = (int) ($row->shared_companies ?? 0);
|
||||
|
||||
if ($totalCompanies === 0) {
|
||||
return [
|
||||
'counters' => [
|
||||
'companies' => 0,
|
||||
'active' => 0,
|
||||
'press_releases' => 0,
|
||||
'contacts' => 0,
|
||||
],
|
||||
'saved_views' => [
|
||||
'all' => 0,
|
||||
'active' => 0,
|
||||
'drafts' => 0,
|
||||
'inactive' => 0,
|
||||
'shared' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$companyIdsQuery = $this->baseQuery($user)->select('companies.id');
|
||||
|
||||
$pressReleaseCount = (int) \App\Models\PressRelease::query()
|
||||
->withoutGlobalScopes()
|
||||
->whereIn('company_id', $companyIdsQuery)
|
||||
->count();
|
||||
|
||||
$contactsCount = (int) \App\Models\Contact::query()
|
||||
->withoutGlobalScopes()
|
||||
->whereIn('company_id', $companyIdsQuery)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'counters' => [
|
||||
'companies' => $totalCompanies,
|
||||
'active' => $activeCompanies,
|
||||
'press_releases' => $pressReleaseCount,
|
||||
'contacts' => $contactsCount,
|
||||
],
|
||||
'saved_views' => [
|
||||
'all' => $totalCompanies,
|
||||
'active' => $activeCompanies,
|
||||
'drafts' => 0,
|
||||
'inactive' => $inactiveCompanies,
|
||||
'shared' => $sharedCompanies,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt deterministisch einen Logo-Token (lg-*) anhand der Company-Id.
|
||||
*/
|
||||
public function logoVariant(Company $company): string
|
||||
{
|
||||
$variants = ['lg-brew', 'lg-mv', 'lg-soft', 'lg-warm'];
|
||||
|
||||
if (blank($company->name)) {
|
||||
return 'lg-blank';
|
||||
}
|
||||
|
||||
return $variants[$company->id % count($variants)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialen aus dem Firmennamen (max. 2 Zeichen, Großbuchstaben).
|
||||
*/
|
||||
public function logoInitials(Company $company): string
|
||||
{
|
||||
$name = trim((string) $company->name);
|
||||
if (blank($name)) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/u', $name) ?: [];
|
||||
$letters = '';
|
||||
foreach ($words as $word) {
|
||||
$first = mb_substr($word, 0, 1);
|
||||
if ($first !== '') {
|
||||
$letters .= $first;
|
||||
}
|
||||
if (mb_strlen($letters) >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($letters === '') {
|
||||
$letters = mb_substr($name, 0, 2);
|
||||
}
|
||||
|
||||
return mb_strtoupper($letters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert eine kompakte Meta-Line: Stadt · Typ.
|
||||
*/
|
||||
public function metaLine(Company $company): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$address = trim((string) ($company->address ?? ''));
|
||||
if (filled($address)) {
|
||||
$lastLine = collect(preg_split('/\r?\n/', $address))
|
||||
->map(fn ($line) => trim((string) $line))
|
||||
->filter()
|
||||
->last();
|
||||
if (is_string($lastLine) && filled($lastLine)) {
|
||||
$parts[] = $lastLine;
|
||||
}
|
||||
}
|
||||
|
||||
$type = $company->type?->label();
|
||||
if (is_string($type) && filled($type)) {
|
||||
$parts[] = $type;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolle des aktuellen Users für die Karte (admin|member).
|
||||
*/
|
||||
public function userRoleKey(Company $company, User $user): string
|
||||
{
|
||||
if ($company->owner_user_id === $user->id) {
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
return (string) ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
|
||||
}
|
||||
|
||||
public function isAdminRole(string $roleKey): bool
|
||||
{
|
||||
return in_array($roleKey, ['owner', 'responsible'], true);
|
||||
}
|
||||
|
||||
public function roleLabel(string $roleKey): string
|
||||
{
|
||||
return match ($roleKey) {
|
||||
'owner' => __('Owner'),
|
||||
'responsible' => __('Verantwortlich'),
|
||||
default => __('Mitglied'),
|
||||
};
|
||||
}
|
||||
|
||||
public function fastLogoUrl(Company $company): ?string
|
||||
{
|
||||
if (blank($company->logo_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$logoPath = trim((string) $company->logo_path);
|
||||
|
||||
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
|
||||
return $logoPath;
|
||||
}
|
||||
|
||||
if (Str::startsWith($logoPath, '/storage/')) {
|
||||
return asset($logoPath);
|
||||
}
|
||||
|
||||
if (filled($company->legacy_portal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
|
||||
return asset('storage/'.ltrim($logoPath, '/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
|
||||
$pressKits = $context->accessibleCompanyQuery($user)
|
||||
->withCount(['contacts', 'pressReleases'])
|
||||
->when(filled($this->search), function ($query): void {
|
||||
$search = trim($this->search);
|
||||
$query = $this->baseQuery($user)
|
||||
->select([
|
||||
'companies.id',
|
||||
'companies.owner_user_id',
|
||||
'companies.portal',
|
||||
'companies.type',
|
||||
'companies.name',
|
||||
'companies.address',
|
||||
'companies.logo_path',
|
||||
'companies.legacy_portal',
|
||||
'companies.is_active',
|
||||
])
|
||||
->addSelect([
|
||||
'current_user_role' => DB::table('company_user')
|
||||
->select('role')
|
||||
->whereColumn('company_user.company_id', 'companies.id')
|
||||
->where('company_user.user_id', $user->id)
|
||||
->limit(1),
|
||||
])
|
||||
->withCount([
|
||||
'contacts' => fn ($q) => $q->withoutGlobalScopes(),
|
||||
'pressReleases' => fn ($q) => $q->withoutGlobalScopes(),
|
||||
])
|
||||
->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScopes()], 'published_at');
|
||||
|
||||
$query->where(function ($query) use ($search): void {
|
||||
$query->where('name', 'like', '%'.$search.'%')
|
||||
->orWhere('email', 'like', '%'.$search.'%')
|
||||
->orWhere('slug', 'like', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
$this->applySavedView($query, $user, $this->savedView);
|
||||
$this->applySharedFilters($query);
|
||||
$this->applyRoleFilter($query, $user, $this->roleFilter);
|
||||
|
||||
$pressKits = $query
|
||||
->orderBy('name')
|
||||
->simplePaginate(24);
|
||||
->paginate(50)
|
||||
->through(function (Company $company) use ($user): Company {
|
||||
$roleKey = $this->userRoleKey($company, $user);
|
||||
$lastPublishedAt = $company->press_releases_max_published_at
|
||||
? Carbon::parse($company->press_releases_max_published_at)
|
||||
: null;
|
||||
|
||||
$company->setAttribute('panel_role_key', $roleKey);
|
||||
$company->setAttribute('panel_is_admin', $this->isAdminRole($roleKey));
|
||||
$company->setAttribute('panel_role_label', $this->roleLabel($roleKey));
|
||||
$company->setAttribute('panel_logo_url', $this->fastLogoUrl($company));
|
||||
$company->setAttribute('panel_logo_variant', $this->logoVariant($company));
|
||||
$company->setAttribute('panel_logo_initials', $this->logoInitials($company));
|
||||
$company->setAttribute('panel_meta_line', $this->metaLine($company));
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_short',
|
||||
$lastPublishedAt?->format('d.m.') ?? '—'
|
||||
);
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_date',
|
||||
$lastPublishedAt?->format('d.m.Y') ?? '—'
|
||||
);
|
||||
|
||||
return $company;
|
||||
});
|
||||
|
||||
$aggregates = $this->buildAggregateCounts($user);
|
||||
|
||||
return [
|
||||
'pressKits' => $pressKits,
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
'hasActiveFilters' => filled($this->search)
|
||||
|| $this->savedView !== 'all'
|
||||
|| filled($this->portalFilter)
|
||||
|| $this->roleFilter !== 'all',
|
||||
'counters' => $aggregates['counters'],
|
||||
'savedViewCounts' => $aggregates['saved_views'],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
|
|
@ -55,102 +425,486 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Meine Firmen') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}
|
||||
|
||||
<div class="counter-strip mt-3">
|
||||
<span class="seg">
|
||||
<b>{{ $counters['companies'] }}</b> {{ __('Firmen') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg is-ok">
|
||||
<b>{{ $counters['active'] }}</b> {{ __('aktiv') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg">
|
||||
<b>{{ $counters['press_releases'] }}</b>
|
||||
{{ __('Pressemitteilungen gesamt') }}
|
||||
</span>
|
||||
<span class="sep"></span>
|
||||
<span class="seg">
|
||||
<b>{{ $counters['contacts'] }}</b>
|
||||
{{ __('Pressekontakte hinterlegt') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-[12.5px] leading-[1.55] max-w-[640px] m-0 text-[color:var(--color-ink-3)]">
|
||||
{{ __('Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Firma anlegen anfragen') }}
|
||||
<flux:button variant="ghost" icon="document-arrow-down" disabled>
|
||||
{{ __('Export') }}
|
||||
<span class="badge muted ml-2" style="font-size:9px;padding:0 5px;letter-spacing:0.06em;">
|
||||
{{ __('bald') }}
|
||||
</span>
|
||||
</flux:button>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||||
{{ __('Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== FILTER-PANEL ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
|
||||
</div>
|
||||
</article>
|
||||
{{-- ============== SAVED VIEW TABS ============== --}}
|
||||
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
|
||||
@php
|
||||
$savedViewMeta = [
|
||||
'all' => __('Alle'),
|
||||
'active' => __('Aktiv'),
|
||||
'drafts' => __('In Anlage'),
|
||||
'inactive' => __('Inaktiv'),
|
||||
'shared' => __('Mit mir geteilt'),
|
||||
];
|
||||
@endphp
|
||||
|
||||
{{-- ============== FIRMEN-CARDS ============== --}}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse ($pressKits as $company)
|
||||
<article class="panel flex flex-col">
|
||||
<div class="panel-head">
|
||||
<div class="min-w-0">
|
||||
<span class="section-eyebrow truncate">{{ $company->name }}</span>
|
||||
</div>
|
||||
@if ($company->is_active)
|
||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||
@foreach ($savedViewMeta as $key => $label)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setSavedView('{{ $key }}')"
|
||||
class="view-tab {{ $savedView === $key ? 'is-active' : '' }}"
|
||||
data-view="{{ $key }}"
|
||||
@if ($key === 'drafts') disabled aria-disabled="true" @endif
|
||||
>
|
||||
{{ $label }}
|
||||
<span class="cnt">{{ $savedViewCounts[$key] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
{{-- ============== FILTER + SUCHE ============== --}}
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<flux:dropdown align="start">
|
||||
<button type="button" class="filter-chip {{ filled($portalFilter) ? 'is-active' : '' }}">
|
||||
@if ($portalFilter === 'presseecho')
|
||||
<span class="dot-pe inline-block"></span>
|
||||
@elseif ($portalFilter === 'businessportal24')
|
||||
<span class="dot-bp inline-block"></span>
|
||||
@else
|
||||
<span class="badge err dot">{{ __('Inaktiv') }}</span>
|
||||
<span class="dot-pe inline-block" style="margin-right:1px;"></span>
|
||||
<span class="dot-bp inline-block" style="margin-left:-2px;"></span>
|
||||
@endif
|
||||
</div>
|
||||
{{ __('Portal') }}:
|
||||
<strong class="font-semibold">
|
||||
@switch($portalFilter)
|
||||
@case('presseecho') presseecho @break
|
||||
@case('businessportal24') businessportal24 @break
|
||||
@default {{ __('Alle') }}
|
||||
@endswitch
|
||||
</strong>
|
||||
<flux:icon.chevron-down class="size-3 caret" />
|
||||
</button>
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="setPortalFilter('')">{{ __('Alle Portale') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setPortalFilter('presseecho')">presseecho</flux:menu.item>
|
||||
<flux:menu.item wire:click="setPortalFilter('businessportal24')">businessportal24</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
|
||||
<div class="p-5 space-y-4 flex-1">
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">
|
||||
{{ $company->slug }}
|
||||
</div>
|
||||
<flux:dropdown align="start">
|
||||
<button type="button" class="filter-chip {{ $roleFilter !== 'all' ? 'is-active' : '' }}">
|
||||
<flux:icon.user class="size-3 opacity-70" />
|
||||
{{ __('Rolle') }}:
|
||||
<strong class="font-semibold">
|
||||
@switch($roleFilter)
|
||||
@case('owner') {{ __('Owner') }} @break
|
||||
@case('responsible') {{ __('Verantwortlich') }} @break
|
||||
@case('member') {{ __('Mitglied') }} @break
|
||||
@default {{ __('Alle') }}
|
||||
@endswitch
|
||||
</strong>
|
||||
<flux:icon.chevron-down class="size-3 caret" />
|
||||
</button>
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="setRoleFilter('all')">{{ __('Alle Rollen') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('owner')">{{ __('Owner') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('responsible')">{{ __('Verantwortlich') }}</flux:menu.item>
|
||||
<flux:menu.item wire:click="setRoleFilter('member')">{{ __('Mitglied') }}</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="badge hub">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</span>
|
||||
<span class="badge hub">{{ $context->roleLabelFor($company, $user) }}</span>
|
||||
@if ($company->disable_footer_code)
|
||||
<span class="badge warn">{{ __('Footer-Code aus') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<button type="button" class="filter-chip" disabled aria-disabled="true" title="{{ __('Branche-Filter folgt') }}">
|
||||
<flux:icon.tag class="size-3 opacity-70" />
|
||||
{{ __('Branche') }}: <strong class="font-semibold">{{ __('bald') }}</strong>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 pt-1">
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pressemitteilungen') }}
|
||||
</div>
|
||||
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $company->press_releases_count }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pressekontakte') }}
|
||||
</div>
|
||||
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $company->contacts_count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-px h-6 bg-[color:var(--color-bg-rule)] mx-1"></span>
|
||||
|
||||
<div class="px-5 pb-4 pt-3 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
@empty
|
||||
<article class="panel md:col-span-2 xl:col-span-3">
|
||||
<div class="p-10 flex flex-col items-center justify-center text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.building-office class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Keine Firmen gefunden') }}
|
||||
</div>
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
|
||||
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
|
||||
</p>
|
||||
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil prüfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
@endforelse
|
||||
<div class="search-wrap" style="max-width:340px;">
|
||||
<flux:icon.magnifying-glass class="ico size-3" />
|
||||
<input
|
||||
type="search"
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Firmenname, Stadt oder E-Mail…') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="flex-1"></span>
|
||||
|
||||
{{-- View-Toggle Karten/Liste --}}
|
||||
<div class="seg-toggle" role="tablist" aria-label="{{ __('Ansicht umschalten') }}">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setViewMode('cards')"
|
||||
class="cursor-pointer {{ $viewMode === 'cards' ? 'is-active' : '' }}"
|
||||
aria-label="{{ __('Kartenansicht') }}"
|
||||
data-viewmode="cards"
|
||||
>
|
||||
<flux:icon.squares-2x2 class="size-3" />
|
||||
{{ __('Karten') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setViewMode('list')"
|
||||
class="cursor-pointer {{ $viewMode === 'list' ? 'is-active' : '' }}"
|
||||
aria-label="{{ __('Listenansicht') }}"
|
||||
data-viewmode="list"
|
||||
>
|
||||
<flux:icon.list-bullet class="size-3" />
|
||||
{{ __('Liste') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ $pressKits->links() }}
|
||||
{{-- ============== CONTENT-HOST ============== --}}
|
||||
<article data-state-host>
|
||||
|
||||
@if ($pressKits->isEmpty())
|
||||
{{-- Empty States --}}
|
||||
@if ($hasActiveFilters)
|
||||
{{-- Empty: Filter ohne Treffer --}}
|
||||
<div class="panel" data-state="empty-filter">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico warm">
|
||||
<flux:icon.funnel class="size-6" />
|
||||
</div>
|
||||
<h3 class="empty-title">{{ __('Keine Firmen mit diesen Filtern') }}</h3>
|
||||
<p class="empty-sub">
|
||||
{{ __('Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<flux:button variant="primary" wire:click="resetFilters">
|
||||
{{ __('Alle Filter zurücksetzen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Empty: noch keine Firma --}}
|
||||
<div class="panel" data-state="empty-none">
|
||||
<div class="empty-stage">
|
||||
<div class="empty-ico">
|
||||
<flux:icon.building-office class="size-6" />
|
||||
</div>
|
||||
<h3 class="empty-title">{{ __('Noch keine Firma angelegt') }}</h3>
|
||||
<p class="empty-sub">
|
||||
{{ __('Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2.5 mt-6">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||||
{{ __('Erste Firma anlegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">01</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Stammdaten erfassen') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">02</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Boilerplate schreiben') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">03</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
{{ __('Pressekontakte zuordnen') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@elseif ($viewMode === 'cards')
|
||||
{{-- Karten-Ansicht --}}
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-state="cards">
|
||||
@foreach ($pressKits as $company)
|
||||
<div
|
||||
class="firm-card {{ $company->panel_is_admin ? 'is-self' : '' }}"
|
||||
wire:key="firm-card-{{ $company->id }}"
|
||||
data-testid="firm-card-{{ $company->id }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
|
||||
@if ($company->panel_logo_url)
|
||||
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
|
||||
@else
|
||||
{{ $company->panel_logo_initials }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($company->is_active)
|
||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||
@else
|
||||
<span class="badge err dot">{{ __('Inaktiv') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h3 class="name">{{ $company->name }}</h3>
|
||||
@if (filled($company->panel_meta_line))
|
||||
<div class="meta-line">{{ $company->panel_meta_line }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@if ($company->portal === \App\Enums\Portal::Both)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
|
||||
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
|
||||
{{ $company->panel_role_label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi">
|
||||
<span class="k">{{ $company->press_releases_count }}</span>
|
||||
<span class="l">{{ __('PMs') }}</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="k">{{ $company->contacts_count }}</span>
|
||||
<span class="l">{{ __('Kontakte') }}</span>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<span class="k">
|
||||
{{ $company->panel_last_press_release_short }}
|
||||
</span>
|
||||
<span class="l">{{ __('letzte PM') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="card-action primary" style="flex:1;">
|
||||
<flux:icon.arrow-right class="size-3" />
|
||||
{{ __('Firma öffnen') }}
|
||||
</a>
|
||||
<a href="{{ route('me.press-releases.create') }}" wire:navigate class="card-action">
|
||||
<flux:icon.plus class="size-3" />
|
||||
{{ __('Neue PM') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Add-Tile am Ende des Grids, nur auf der letzten Seite --}}
|
||||
@if ($pressKits->currentPage() === $pressKits->lastPage())
|
||||
<a href="{{ route('me.press-kits.create') }}" wire:navigate class="add-tile" data-testid="add-tile">
|
||||
<span class="ico">
|
||||
<flux:icon.plus class="size-5" />
|
||||
</span>
|
||||
<span class="lbl">{{ __('Neue Firma anlegen') }}</span>
|
||||
<span class="sub">
|
||||
{{ __('Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.') }}
|
||||
</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@else
|
||||
{{-- Listen-Ansicht --}}
|
||||
<div class="panel overflow-hidden" data-state="list">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="list">
|
||||
<colgroup>
|
||||
<col style="width:88px;" />
|
||||
<col />
|
||||
<col style="width:190px;" />
|
||||
<col style="width:140px;" />
|
||||
<col style="width:110px;" />
|
||||
<col style="width:80px;" />
|
||||
<col style="width:100px;" />
|
||||
<col style="width:130px;" />
|
||||
<col style="width:56px;" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{{ __('Firma') }}</th>
|
||||
<th>{{ __('Portal') }}</th>
|
||||
<th>{{ __('Rolle') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th style="text-align:right;">{{ __('PMs') }}</th>
|
||||
<th style="text-align:right;">{{ __('Kontakte') }}</th>
|
||||
<th>{{ __('Letzte PM') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($pressKits as $company)
|
||||
<tr wire:key="firm-row-{{ $company->id }}" data-testid="firm-row-{{ $company->id }}">
|
||||
<td>
|
||||
<span class="mini-logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
|
||||
@if ($company->panel_logo_url)
|
||||
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
|
||||
@else
|
||||
{{ $company->panel_logo_initials }}
|
||||
@endif
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="row-title">
|
||||
{{ $company->name }}
|
||||
</a>
|
||||
@if (filled($company->panel_meta_line))
|
||||
<div class="row-sub">{{ $company->panel_meta_line }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
@if ($company->portal === \App\Enums\Portal::Both)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
|
||||
{{ $company->panel_role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if ($company->is_active)
|
||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||
@else
|
||||
<span class="badge err dot">{{ __('Inaktiv') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<span class="row-num">{{ $company->press_releases_count }}</span>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<span class="row-num">{{ $company->contacts_count }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="row-num">
|
||||
{{ $company->panel_last_press_release_date }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<div class="firm-list-actions flex items-center justify-end gap-1">
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="eye"
|
||||
href="{{ route('me.press-kits.show', $company->id) }}"
|
||||
wire:navigate
|
||||
aria-label="{{ __('Firma öffnen') }}"
|
||||
title="{{ __('Firma öffnen') }}"
|
||||
/>
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="document-plus"
|
||||
href="{{ route('me.press-releases.create') }}"
|
||||
wire:navigate
|
||||
aria-label="{{ __('Neue Pressemitteilung') }}"
|
||||
title="{{ __('Neue Pressemitteilung') }}"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</article>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if ($pressKits->hasPages())
|
||||
<div class="px-1">
|
||||
{{ $pressKits->links('components.portal.pagination', ['scrollTo' => '[data-state-host]']) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ============== ROLLEN-LEGENDE ============== --}}
|
||||
<article class="panel-warm p-5">
|
||||
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
|
||||
<div class="min-w-[180px]">
|
||||
<div class="section-eyebrow">{{ __('Rollen pro Firma') }}</div>
|
||||
<p class="text-[12px] leading-[1.55] mt-3 m-0 max-w-[220px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Mehrere Personen können einer Firma zugeordnet sein. Die Rolle steuert, was im Backend möglich ist.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
|
||||
<div>
|
||||
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Owner') }}</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('Stammdaten & Boilerplate') }}</li>
|
||||
<li>{{ __('Pressekontakte verwalten') }}</li>
|
||||
<li>{{ __('PMs erstellen, einreichen, archivieren') }}</li>
|
||||
<li>{{ __('Weitere Mitglieder einladen') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Verantwortlich') }}</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('Stammdaten & Boilerplate') }}</li>
|
||||
<li>{{ __('Pressekontakte verwalten') }}</li>
|
||||
<li>{{ __('PMs erstellen & einreichen') }}</li>
|
||||
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Mitglieder-Verwaltung') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="role-pill" style="margin-bottom:8px;">
|
||||
{{ __('Mitglied') }}
|
||||
<span class="text-[color:var(--color-ink-4)] font-normal">· {{ __('bald erweitert') }}</span>
|
||||
</span>
|
||||
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
|
||||
<li>{{ __('PMs einsehen') }}</li>
|
||||
<li>{{ __('Stammdaten lesen') }}</li>
|
||||
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Bearbeitung') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\Contact;
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -44,6 +45,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public string $publishMode = 'now';
|
||||
|
||||
public ?string $scheduledAt = null;
|
||||
|
||||
public bool $useEmbargo = false;
|
||||
|
||||
public ?string $embargoAt = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
|
@ -72,6 +79,86 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
|
||||
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
|
||||
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
|
||||
* der User muss nicht erst auf „Entwurf speichern" klicken.
|
||||
*/
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if (! $this->getErrorBag()->has($property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateOnly($property, $this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException) {
|
||||
// Field bleibt invalid — Error-Bag wird automatisch befüllt.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung.
|
||||
* Die einzelnen Feld-Errors werden weiterhin direkt am Input angezeigt,
|
||||
* der Toast dient als zusätzlicher Wegweiser, falls der erste Fehler
|
||||
* außerhalb des Viewports liegt.
|
||||
*/
|
||||
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
|
||||
{
|
||||
$count = $exception
|
||||
? array_sum(array_map('count', $exception->errors()))
|
||||
: count($this->getErrorBag()->all());
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single Source of Truth für die Validierungsregeln.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function formRules(): array
|
||||
{
|
||||
$rules = [
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||
'contactId' => ['nullable', 'integer'],
|
||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'text' => ['required', 'string', 'min:50'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||||
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
|
||||
];
|
||||
|
||||
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
|
||||
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
|
||||
// die PM verlässlich rechtzeitig fängt.
|
||||
if ($this->publishMode === 'scheduled') {
|
||||
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
|
||||
} else {
|
||||
$rules['scheduledAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
if ($this->useEmbargo) {
|
||||
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
|
||||
} else {
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function addTag(string $tag): void
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
|
@ -110,34 +197,35 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public function save(string $submitStatus = 'draft'): void
|
||||
{
|
||||
$this->validate([
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||
'contactId' => ['required', 'integer'],
|
||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||
'text' => ['required', 'string', 'min:50'],
|
||||
'keywords' => ['nullable', 'string', 'max:255'],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
try {
|
||||
$this->validate($this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->notifyValidationError($e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if (! $company) {
|
||||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||||
$this->notifyValidationError();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||||
$contact = null;
|
||||
|
||||
if (! $contact) {
|
||||
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
|
||||
if ($this->contactId) {
|
||||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||||
|
||||
return;
|
||||
if (! $contact) {
|
||||
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
|
||||
$this->notifyValidationError();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||
|
|
@ -167,14 +255,28 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
: null,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
|
||||
? \Carbon\Carbon::parse($this->scheduledAt)
|
||||
: null,
|
||||
'embargo_at' => $this->useEmbargo && $this->embargoAt
|
||||
? \Carbon\Carbon::parse($this->embargoAt)
|
||||
: null,
|
||||
'status' => $status->value,
|
||||
]);
|
||||
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
if ($contact) {
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
}
|
||||
|
||||
session()->flash('success', $status === PressReleaseStatus::Review
|
||||
? __('Pressemitteilung zur Prüfung eingereicht.')
|
||||
: __('Entwurf gespeichert.'));
|
||||
Flux::toast(
|
||||
heading: $status === PressReleaseStatus::Review
|
||||
? __('Eingereicht')
|
||||
: __('Entwurf gespeichert'),
|
||||
text: $status === PressReleaseStatus::Review
|
||||
? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.')
|
||||
: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
}
|
||||
|
|
@ -246,9 +348,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
],
|
||||
[
|
||||
'key' => 'contact',
|
||||
'status' => $this->contactId ? 'ok' : 'err',
|
||||
'status' => $this->contactId ? 'ok' : 'warn',
|
||||
'label' => __('Pressekontakt zugeordnet'),
|
||||
'sub' => $this->contactId ? '' : __('Mindestens ein Kontakt erforderlich'),
|
||||
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
|
||||
],
|
||||
[
|
||||
'key' => 'tags',
|
||||
|
|
@ -365,7 +467,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</header>
|
||||
|
||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
|
||||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||
<div class="space-y-6 min-w-0">
|
||||
|
|
@ -513,7 +615,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 6) ANHÄNGE (nach Speichern verfügbar) --}}
|
||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||||
Wird in einer späteren Phase aktiviert.
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
|
|
@ -529,6 +634,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
--}}
|
||||
|
||||
{{-- 7) BOILERPLATE --}}
|
||||
<section class="panel">
|
||||
|
|
@ -655,14 +761,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only) --}}
|
||||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">
|
||||
{{ __('Kategorie') }}
|
||||
<span class="text-[color:var(--color-err)]">*</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:field>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
|
||||
@php
|
||||
$portalPillClass = 'portal-pill';
|
||||
if ($portal === 'presseecho') {
|
||||
$portalPillClass = 'portal-pill pe';
|
||||
} elseif ($portal === 'businessportal24') {
|
||||
$portalPillClass = 'portal-pill bp';
|
||||
}
|
||||
@endphp
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Portal') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge hub dot">{{ $selectedPortalLabel }}</span>
|
||||
<span class="{{ $portalPillClass }}">
|
||||
<span class="pdot"></span>{{ $selectedPortalLabel }}
|
||||
</span>
|
||||
<span class="text-[11px] text-[color:var(--color-ink-4)]">
|
||||
{{ __('automatisch aus der Firma') }}
|
||||
</span>
|
||||
|
|
@ -691,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
@endif
|
||||
@else
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kontakt für diese PM') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
||||
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
|
||||
<flux:select wire:model.live="contactId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($selectedCompanyContacts as $contact)
|
||||
|
|
@ -708,18 +846,25 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<flux:error name="contactId" />
|
||||
</flux:field>
|
||||
|
||||
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
||||
@if ($activeContact && empty($activeContact->phone))
|
||||
@if (! $contactId)
|
||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
|
||||
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
|
||||
</div>
|
||||
@else
|
||||
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
||||
@if ($activeContact && empty($activeContact->phone))
|
||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Themen-Tags + Kategorie --}}
|
||||
{{-- Themen-Tags --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
|
||||
|
|
@ -769,20 +914,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
|
||||
<option value="{{ $cat->id }}">{{ $catName }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
</flux:field>
|
||||
|
||||
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
|
||||
{{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }}
|
||||
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -792,7 +925,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
<div class="p-5 space-y-3">
|
||||
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
|
||||
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
|
||||
<span class="dot-out"></span>
|
||||
|
|
@ -805,18 +938,53 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="pr-pub-opt is-disabled">
|
||||
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
|
||||
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
|
||||
<span class="dot-out"></span>
|
||||
<span class="flex-1">
|
||||
<span class="text-[12.5px] font-semibold text-[color:var(--color-ink-2)] leading-tight flex items-center gap-2">
|
||||
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
|
||||
{{ __('Geplanter Termin') }}
|
||||
<span class="pr-bald-badge">{{ __('bald') }}</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
||||
{{ __('Datum + Uhrzeit, automatische Veröffentlichung') }}
|
||||
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if ($publishMode === 'scheduled')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="scheduledAt"
|
||||
type="datetime-local"
|
||||
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
|
||||
<flux:error name="scheduledAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="border-t pt-3" style="border-color: var(--color-line);">
|
||||
<flux:switch
|
||||
wire:model.live="useEmbargo"
|
||||
:label="__('Sperrfrist (Embargo) setzen')"
|
||||
/>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
|
||||
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
|
||||
</p>
|
||||
|
||||
@if ($useEmbargo)
|
||||
<flux:field class="mt-3">
|
||||
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="embargoAt"
|
||||
type="datetime-local"
|
||||
:min="now()->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:error name="embargoAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -851,7 +1019,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
||||
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
||||
<li>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
|
||||
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
||||
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
||||
</ul>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ use App\Models\PressRelease;
|
|||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -86,11 +87,20 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Pressemitteilung zur Prüfung eingereicht.'),
|
||||
variant: 'success',
|
||||
);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,19 +168,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
|
||||
<div class="space-y-8">
|
||||
|
||||
{{-- ============== FLASH ============== --}}
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
|
||||
style="border-color: var(--color-ok); background: color-mix(in oklab, var(--color-ok) 10%, var(--color-bg)); color: var(--color-ink);">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border-l-[3px] text-[12.5px]"
|
||||
style="border-color: var(--color-err); background: color-mix(in oklab, var(--color-err) 12%, var(--color-bg)); color: var(--color-ink);">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -504,6 +502,18 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||||
</div>
|
||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.calendar variant="micro" class="size-3" />
|
||||
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
|
|
@ -520,7 +530,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
@endforeach
|
||||
</flux:table>
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $pressReleases->links() }}
|
||||
{{ $pressReleases->links('components.portal.pagination') }}
|
||||
</div>
|
||||
@elseif ($hasAnyPR && $search !== '')
|
||||
{{-- Empty: Suche ohne Treffer --}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Models\PressRelease;
|
|||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -34,12 +35,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
|
||||
variant: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
public function generateShareLink(MagicLinkGenerator $generator): void
|
||||
|
|
@ -52,7 +62,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$this->shareUrl = $share['url'];
|
||||
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
|
||||
|
||||
session()->flash('success', __('Vorschau-Link wurde erzeugt.'));
|
||||
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -114,18 +124,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
};
|
||||
@endphp
|
||||
|
||||
@if (session('success'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('error'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
||||
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
|
||||
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
|
|
@ -139,6 +138,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ $pr->title }}
|
||||
</h1>
|
||||
@if ($pr->subtitle)
|
||||
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
{{ $pr->subtitle }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
|
||||
{{ $pr->company?->name ?? '–' }}
|
||||
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
|
||||
|
|
@ -333,8 +337,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{{ number_format($pr->hits, 0, ',', '.') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($pr->scheduled_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->embargo_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pr->no_export)
|
||||
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.no-symbol variant="micro" class="size-3.5" />
|
||||
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
|
||||
|
||||
@if ($statusLogs->isNotEmpty())
|
||||
|
|
@ -406,4 +433,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
@if ($pr->boilerplate_override)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
|
||||
<span class="badge hub">{{ __('Override') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
|
||||
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
|
||||
</p>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
|
||||
{{ $pr->boilerplate_override }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Profile;
|
||||
use App\Models\User;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $language = 'de';
|
||||
|
|
@ -53,26 +47,6 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public string $billingCountryCode = 'DE';
|
||||
|
||||
public ?int $editableCompanyId = null;
|
||||
|
||||
public string $companyName = '';
|
||||
|
||||
public string $companyAddress = '';
|
||||
|
||||
public string $companyEmail = '';
|
||||
|
||||
public string $companyPhone = '';
|
||||
|
||||
public string $companyWebsite = '';
|
||||
|
||||
public string $companyCountryCode = 'DE';
|
||||
|
||||
public bool $companyDisableFooterCode = false;
|
||||
|
||||
public $companyLogo = null;
|
||||
|
||||
public bool $removeCompanyLogo = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
|
@ -81,7 +55,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$this->name = (string) $user->name;
|
||||
$this->language = $user->language ?? 'de';
|
||||
|
||||
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
|
||||
$this->salutationKey = (string) ($profile?->salutation_key ?? 'none');
|
||||
$this->firstName = (string) ($profile?->first_name ?? '');
|
||||
$this->lastName = (string) ($profile?->last_name ?? '');
|
||||
$this->title = (string) ($profile?->title ?? '');
|
||||
|
|
@ -94,20 +68,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
|
||||
|
||||
$billingAddress = $user->billingAddress;
|
||||
$this->billingName = (string) ($billingAddress?->name ?? $user->name);
|
||||
$this->billingName = (string) ($billingAddress?->name ?? '');
|
||||
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
|
||||
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
|
||||
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
|
||||
$this->billingCity = (string) ($billingAddress?->city ?? '');
|
||||
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
|
||||
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function selectCompany(int $companyId): void
|
||||
{
|
||||
$this->editableCompanyId = $companyId;
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
|
|
@ -184,139 +150,20 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
session()->flash('profile-status', __('Profil gespeichert.'));
|
||||
}
|
||||
|
||||
public function saveCompany(ImageService $imageService): void
|
||||
{
|
||||
if (! $this->editableCompanyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $this->resolveEditableCompany($this->editableCompanyId);
|
||||
|
||||
if (! $company) {
|
||||
throw ValidationException::withMessages([
|
||||
'companyName' => __('Diese Firma kann von Ihnen nicht bearbeitet werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$validated = $this->validate([
|
||||
'companyName' => ['required', 'string', 'max:255'],
|
||||
'companyAddress' => ['nullable', 'string', 'max:1000'],
|
||||
'companyEmail' => ['nullable', 'email', 'max:190'],
|
||||
'companyPhone' => ['nullable', 'string', 'max:40'],
|
||||
'companyWebsite' => ['nullable', 'url', 'max:190'],
|
||||
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'companyLogo' => ['nullable', 'image', 'max:'.(int) (ImageService::MAX_LOGO_BYTES / 1024)],
|
||||
]);
|
||||
|
||||
$company->fill([
|
||||
'name' => $validated['companyName'],
|
||||
'address' => $validated['companyAddress'] ?: null,
|
||||
'email' => $validated['companyEmail'] ?: null,
|
||||
'phone' => $validated['companyPhone'] ?: null,
|
||||
'website' => $validated['companyWebsite'] ?: null,
|
||||
'country_code' => $validated['companyCountryCode'] ?: null,
|
||||
'disable_footer_code' => $this->companyDisableFooterCode,
|
||||
]);
|
||||
|
||||
if ($this->removeCompanyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
$company->logo_path = null;
|
||||
$company->logo_variants = null;
|
||||
}
|
||||
|
||||
if ($this->companyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
|
||||
$stored = $imageService->storeCompanyLogo(
|
||||
$this->companyLogo,
|
||||
$company->portal?->value ?? 'presseecho',
|
||||
$company->id,
|
||||
);
|
||||
|
||||
$company->logo_path = $stored['path'];
|
||||
$company->logo_variants = $stored['variants'];
|
||||
}
|
||||
|
||||
$company->save();
|
||||
|
||||
$this->companyLogo = null;
|
||||
$this->removeCompanyLogo = false;
|
||||
|
||||
session()->flash('company-status', __('Firmendaten gespeichert.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$companies = $user->companies()
|
||||
->withPivot('role')
|
||||
->orderBy('name')
|
||||
->get(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id']);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'companies' => $companies,
|
||||
'salutations' => collect((array) config('salutations.items', []))
|
||||
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
|
||||
->all(),
|
||||
'countries' => (array) config('countries.items', []),
|
||||
'editableCompany' => $this->editableCompanyId
|
||||
? $this->resolveEditableCompany($this->editableCompanyId)
|
||||
: null,
|
||||
'billingComplete' => $this->billingIsComplete(),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadEditableCompany(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$editable = Company::query()
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->orderBy('name');
|
||||
|
||||
$company = $this->editableCompanyId
|
||||
? $editable->whereKey($this->editableCompanyId)->first()
|
||||
: $editable->first();
|
||||
|
||||
if (! $company) {
|
||||
$this->editableCompanyId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editableCompanyId = $company->id;
|
||||
$this->companyName = (string) $company->name;
|
||||
$this->companyAddress = (string) ($company->address ?? '');
|
||||
$this->companyEmail = (string) ($company->email ?? '');
|
||||
$this->companyPhone = (string) ($company->phone ?? '');
|
||||
$this->companyWebsite = (string) ($company->website ?? '');
|
||||
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
|
||||
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
|
||||
}
|
||||
|
||||
private function resolveEditableCompany(int $companyId): ?Company
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
return Company::query()
|
||||
->where('id', $companyId)
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
public function billingHasInput(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
|
|
@ -347,10 +194,15 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Mein Profil') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen verwalten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (session('profile-status'))
|
||||
|
|
@ -362,60 +214,42 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
@endif
|
||||
|
||||
<form wire:submit="saveProfile" class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Konto') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:input wire:model="name" :label="__('Name')" required />
|
||||
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
|
||||
<flux:select wire:model="language" :label="__('Sprache')">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel" id="profil">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Profil') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:checkbox wire:model="showStats" :label="__('Statistiken in Pressemitteilungen anzeigen')" class="sm:col-span-2" />
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code in Pressemitteilungen deaktivieren')" class="sm:col-span-2" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel" id="rechnungsadresse">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
|
||||
</p>
|
||||
|
||||
@if (! $this->billingIsComplete())
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
|
||||
</div>
|
||||
</div>
|
||||
@if ($billingComplete)
|
||||
<span class="badge ok dot">{{ __('vollständig') }}</span>
|
||||
@else
|
||||
<span class="badge warn dot">{{ __('unvollständig') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div class="space-y-3">
|
||||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }}
|
||||
</p>
|
||||
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }}
|
||||
</p>
|
||||
|
||||
@if (! $billingComplete)
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
|
||||
|
|
@ -432,52 +266,86 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
<flux:error name="billingName" class="sm:col-span-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Rechnungsadresse speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<article class="panel" id="profil">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Profileinstellungen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:textarea wire:model="address" :label="__('Adresse')" class="sm:col-span-2" />
|
||||
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Profil speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Konto & Sicherheit') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
|
||||
<flux:select wire:model="language" :label="__('Sprache')">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</flux:select>
|
||||
<div class="pt-3 border-t border-[color:var(--color-bg-rule)]">
|
||||
<flux:button size="sm" variant="ghost" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
|
||||
{{ __('Konto-Sicherheit öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex justify-end">
|
||||
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
|
||||
<div class="p-5 grid gap-4 md:grid-cols-2">
|
||||
<flux:switch
|
||||
wire:model="showStats"
|
||||
align="right"
|
||||
:label="__('Statistiken anzeigen')"
|
||||
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen.')"
|
||||
/>
|
||||
<flux:switch
|
||||
wire:model="disableFooterCode"
|
||||
align="right"
|
||||
:label="__('Footer-Code deaktivieren')"
|
||||
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Einstellungen speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zugeordnete Firmen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $companies->count() }} {{ __('Einträge') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@forelse ($companies as $company)
|
||||
<div class="flex flex-col gap-2 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0">{{ $company->name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="badge hub">{{ $company->portal?->label() ?? '–' }}</span>
|
||||
<span class="badge hub">{{ $company->pivot->role ?? 'member' }}</span>
|
||||
@if ($company->owner_user_id === $user->id)
|
||||
<span class="badge ok">{{ __('Eigentümer') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true))
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma verwalten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-5 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</article>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue