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

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

View file

@ -0,0 +1,52 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
{
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Buchungen & Add-ons') }}</flux:heading>
<flux:subheading>
{{ __('Hier werden künftig gebuchte Leistungen, Add-ons und Erweiterungen für Ihre Firmen gebündelt.') }}
</flux:subheading>
</div>
<flux:badge color="zinc" icon="shopping-bag" size="lg">
{{ __('In Vorbereitung') }}
</flux:badge>
</div>
</flux:card>
<flux:callout color="blue" icon="information-circle">
{{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }}
</flux:callout>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<flux:card>
<flux:heading size="sm">{{ __('Firmenbezogene Add-ons') }}</flux:heading>
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }}
</flux:text>
</flux:card>
<flux:card>
<flux:heading size="sm">{{ __('Credits & Tarif') }}</flux:heading>
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }}
</flux:text>
</flux:card>
<flux:card>
<flux:heading size="sm">{{ __('Zahlungsarten') }}</flux:heading>
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }}
</flux:text>
</flux:card>
</div>
</div>

View file

@ -0,0 +1,91 @@
<?php
use App\Services\Customer\CustomerCompanyContext;
use Livewire\Volt\Component;
new class extends Component
{
public string $activeCompany = 'all';
public function mount(CustomerCompanyContext $context): void
{
$companyId = $context->selectedCompanyId(auth()->user());
$this->activeCompany = $companyId === null ? 'all' : (string) $companyId;
}
public function updatedActiveCompany(CustomerCompanyContext $context): void
{
if ($this->activeCompany === 'all') {
$context->select(auth()->user(), null);
} elseif (is_numeric($this->activeCompany)) {
$context->select(auth()->user(), (int) $this->activeCompany);
}
$this->redirect($this->redirectTarget(), navigate: false);
}
public function with(CustomerCompanyContext $context): array
{
$user = auth()->user();
return [
'companies' => $context->companiesFor($user),
'selectedCompany' => $context->selectedCompany($user),
'context' => $context,
'user' => $user,
];
}
private function redirectTarget(): string
{
return (string) request()->headers->get('referer', route('me.dashboard'));
}
}; ?>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
@if($companies->isNotEmpty())
<div class="hidden text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400 sm:block">
{{ __('Aktive Firma') }}
</div>
<div class="min-w-0 sm:w-72">
<flux:select wire:model.live="activeCompany" size="sm">
<option value="all">{{ __('Alle Firmen') }}</option>
@foreach($companies as $company)
<option value="{{ $company->id }}">
{{ $company->name }} · {{ $context->roleLabelFor($company, $user) }}
</option>
@endforeach
</flux:select>
</div>
<div class="hidden max-w-48 truncate text-xs text-zinc-500 dark:text-zinc-400 lg:block">
@if($selectedCompany)
{{ $selectedCompany->portal?->label() ?? __('Portal unbekannt') }}
@else
{{ __('Aggregierte Sicht') }}
@endif
</div>
@if($selectedCompany)
<flux:button
size="sm"
variant="ghost"
icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}"
wire:navigate
>
{{ __('Firma öffnen') }}
</flux:button>
@else
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen') }}
</flux:button>
@endif
@else
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{{ __('Keine Firma zugeordnet') }}
</div>
@endif
</div>

View file

@ -0,0 +1,237 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component
{
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId($user);
$selectedCompany = $context->selectedCompany($user);
$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');
$recent = (clone $pressReleaseQuery)
->with('company:id,name')
->latest()
->limit(5)
->get(['id', 'title', 'status', 'company_id', 'created_at']);
return [
'user' => $user,
'selectedCompany' => $selectedCompany,
'stats' => [
'total' => (clone $pressReleaseQuery)->count(),
'published' => $myPRs->get('published', 0),
'review' => $myPRs->get('review', 0),
'draft' => $myPRs->get('draft', 0),
],
'qualityHints' => $this->qualityHints($user, $selectedCompany, $pressReleaseQuery),
'recent' => $recent,
'companies' => $context->companiesFor($user),
];
}
private function qualityHints(User $user, ?Company $selectedCompany, Builder $pressReleaseQuery): array
{
$hints = [];
if (! $user->profile()->exists()) {
$hints[] = [
'color' => 'amber',
'icon' => 'user',
'title' => __('Profil unvollständig'),
'description' => __('Ergänzen Sie Ihre Profildaten für eine sauberere Kundenakte.'),
'href' => route('me.profile').'#profil',
'action' => __('Profil öffnen'),
];
}
if (! $user->billingAddress()->exists()) {
$hints[] = [
'color' => 'amber',
'icon' => 'archive-box',
'title' => __('Rechnungsadresse fehlt'),
'description' => __('Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.'),
'href' => route('me.profile').'#rechnungsadresse',
'action' => __('Rechnungsadresse ergänzen'),
];
}
if ($selectedCompany) {
$contactsCount = Contact::withoutGlobalScopes()
->where('company_id', $selectedCompany->id)
->count();
if ($contactsCount === 0) {
$hints[] = [
'color' => 'blue',
'icon' => 'user-group',
'title' => __('Keine Pressekontakte hinterlegt'),
'description' => __('Ergänzen Sie Pressekontakte für diese Firma.'),
'href' => route('me.press-kits.show', $selectedCompany->id),
'action' => __('Firma öffnen'),
];
}
} else {
$unassignedPressReleasesCount = (clone $pressReleaseQuery)
->whereNull('company_id')
->count();
if ($unassignedPressReleasesCount > 0) {
$hints[] = [
'color' => 'amber',
'icon' => 'newspaper',
'title' => trans_choice(':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma', $unassignedPressReleasesCount, ['count' => $unassignedPressReleasesCount]),
'description' => __('Ordnen Sie Legacy-Pressemitteilungen einer Firma zu, damit Portal und Pressekontakte eindeutig sind.'),
'href' => route('me.press-releases.index', ['company' => 'unassigned']),
'action' => __('Pressemitteilungen prüfen'),
];
}
}
return $hints;
}
}; ?>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Willkommen, :name', ['name' => $user->name]) }}</flux:heading>
<flux:subheading>
{{ $selectedCompany
? __('Übersicht für :company', ['company' => $selectedCompany->name])
: __('Übersicht Ihres Kundenkontos') }}
</flux:subheading>
</flux:card>
{{-- Statistiken --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
</flux:card>
</div>
@if($qualityHints)
<flux:card>
<div class="mb-4">
<flux:heading size="sm">{{ __('Datenqualität') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">{{ __('Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.') }}</flux:text>
</div>
<div class="grid gap-3 lg:grid-cols-3">
@foreach($qualityHints as $hint)
<a href="{{ $hint['href'] }}" wire:navigate class="rounded-lg border border-zinc-200 p-4 transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900">
<div class="flex items-start gap-3">
<flux:badge color="{{ $hint['color'] }}" size="sm" icon="{{ $hint['icon'] }}" />
<div class="min-w-0 flex-1">
<flux:text weight="semibold">{{ $hint['title'] }}</flux:text>
<flux:text class="mt-1 text-sm text-zinc-500">{{ $hint['description'] }}</flux:text>
<flux:text class="mt-3 text-xs font-medium text-zinc-700 dark:text-zinc-300">
{{ $hint['action'] ?? __('Öffnen') }} &rarr;
</flux:text>
</div>
</div>
</a>
@endforeach
</div>
</flux:card>
@endif
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
{{-- Letzte Pressemitteilungen --}}
<flux:card class="p-0">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Meine letzten Pressemitteilungen') }}</flux:heading>
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($recent as $pr)
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">{{ $pr->company?->name ?? '' }} · {{ $pr->created_at->format('d.m.Y') }}</p>
</div>
<flux:badge color="{{ match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
} }}" size="sm">
{{ $pr->status->label() }}
</flux:badge>
</a>
@empty
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Noch keine Pressemitteilungen') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Starten Sie mit einer ersten Pressemitteilung für die aktive Firma oder für Ihr Kundenkonto.') }}
</flux:text>
<flux:button class="mt-4" variant="primary" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Erste Pressemitteilung erstellen') }}
</flux:button>
</div>
@endforelse
</div>
</flux:card>
{{-- Zugeordnete Firmen --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Meine Firmen') }}</flux:heading>
@forelse($companies as $company)
<div class="py-2 text-sm">
<p class="font-medium">{{ $company->name }}</p>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
</div>
@endforelse
<div class="mt-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil & Firma verwalten') }}
</flux:button>
</div>
</flux:card>
</div>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>

View file

@ -0,0 +1,194 @@
<?php
use App\Models\LegacyInvoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $statusFilter = 'all';
public ?string $notification = null;
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function with(): array
{
$baseQuery = LegacyInvoice::query()
->where('user_id', auth()->id());
$invoices = (clone $baseQuery)
->when(filled($this->search), function ($query): void {
$query->where('number', 'like', '%'.$this->search.'%');
})
->when($this->statusFilter !== 'all', fn ($query) => $query->where('status', $this->statusFilter))
->latest('invoice_date')
->paginate(100);
return [
'invoices' => $invoices,
'statusOptions' => (clone $baseQuery)
->whereNotNull('status')
->distinct()
->orderBy('status')
->pluck('status')
->filter()
->values(),
'stats' => [
'count' => (clone $baseQuery)->count(),
'total_cents' => (int) (clone $baseQuery)->sum('total_cents'),
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count(),
'downloadable_count' => (clone $baseQuery)->count(),
],
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Rechnungen') }}</flux:heading>
<flux:subheading>{{ __('Ihr Rechnungsarchiv im User Backend. PDFs werden bei Bedarf aus den Archivdaten erzeugt.') }}</flux:subheading>
</div>
<flux:badge color="zinc" icon="archive-box" size="lg">
{{ __('Archivdaten') }}
</flux:badge>
</div>
</flux:card>
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:heading size="sm">{{ __('Hinweis zu Rechnungen') }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Aktuell sehen Sie hier die aus dem Legacy-System übernommenen Rechnungen. Neue Abrechnungen werden später in dieselbe Finanznavigation integriert.') }}
</flux:text>
</div>
<flux:button size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
{{ __('Rechnungsadresse im Profil pflegen') }}
</flux:button>
</div>
</flux:card>
@if($notification)
<flux:callout color="yellow" icon="exclamation-triangle">
{{ $notification }}
</flux:callout>
@endif
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Rechnungen') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['count'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Archivsumme') }}</flux:text>
<flux:text size="xl" weight="bold">{{ number_format($stats['total_cents'] / 100, 2, ',', '.') }} </flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Bezahlt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['paid_count'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('PDF-Download') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['downloadable_count'] }}</flux:text>
</flux:card>
</div>
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Rechnungsnummer suchen…') }}" icon="magnifying-glass" class="flex-1" />
<flux:select wire:model.live="statusFilter" class="sm:w-48">
<option value="all">{{ __('Alle Status') }}</option>
@foreach($statusOptions as $status)
<option value="{{ $status }}">{{ $status }}</option>
@endforeach
</flux:select>
</div>
</flux:card>
<flux:card class="p-0">
<div class="p-4">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Betrag') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('Bezahlt am') }}</flux:table.column>
<flux:table.column>{{ __('PDF') }}</flux:table.column>
</flux:table.columns>
@forelse($invoices as $invoice)
<flux:table.row wire:key="legacy-invoice-{{ $invoice->id }}">
<flux:table.cell>
<flux:text weight="semibold">{{ $invoice->number ?? ('#'.$invoice->legacy_id) }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="zinc">{{ $invoice->legacy_portal?->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text weight="semibold">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} </flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" color="{{ $invoice->paid_at ? 'green' : 'yellow' }}">
{{ $invoice->status ?? ($invoice->paid_at ? __('Bezahlt') : __('Offen')) }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $invoice->invoice_date?->format('d.m.Y') ?? '' }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $invoice->paid_at?->format('d.m.Y') ?? '' }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:button
size="sm"
variant="ghost"
icon="arrow-top-right-on-square"
:href="route('me.invoices.pdf', $invoice)"
target="_blank"
>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.document-text class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine Rechnungen gefunden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Sobald Rechnungen aus dem Archiv oder aus neuen Buchungen vorhanden sind, erscheinen sie hier.') }}
</flux:text>
<flux:button class="mt-4" size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
{{ __('Rechnungsadresse prüfen') }}
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</div>
</flux:card>
{{ $invoices->links() }}
</div>

View file

@ -0,0 +1,119 @@
<?php
use App\Services\Customer\CustomerCompanyContext;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Component
{
use WithPagination;
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
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->where(function ($query) use ($search): void {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('slug', 'like', '%'.$search.'%');
});
})
->orderBy('name')
->simplePaginate(24);
return [
'pressKits' => $pressKits,
'context' => $context,
'user' => $user,
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Meine Firmen') }}</flux:heading>
<flux:subheading>{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}</flux:subheading>
</div>
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Firma anlegen anfragen') }}
</flux:button>
</div>
</flux:card>
<flux:card>
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
</flux:card>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse($pressKits as $company)
<flux:card class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<flux:heading size="sm" class="truncate">{{ $company->name }}</flux:heading>
<flux:text class="mt-1 text-xs text-zinc-500">{{ $company->slug }}</flux:text>
</div>
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</div>
<div class="flex flex-wrap gap-2">
<flux:badge color="zinc" size="sm">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</flux:badge>
<flux:badge color="indigo" size="sm">{{ $context->roleLabelFor($company, $user) }}</flux:badge>
@if($company->disable_footer_code)
<flux:badge color="amber" size="sm">{{ __('Footer-Code aus') }}</flux:badge>
@endif
</div>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
<flux:text size="lg" weight="bold">{{ $company->press_releases_count }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
<flux:text size="lg" weight="bold">{{ $company->contacts_count }}</flux:text>
</div>
</div>
<div class="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>
</flux:card>
@empty
<flux:card class="md:col-span-2 xl:col-span-3">
<div class="flex flex-col items-center justify-center py-10 text-center">
<flux:icon.building-office class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine Firmen gefunden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
</flux:text>
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil prüfen') }}
</flux:button>
</div>
</flux:card>
@endforelse
</div>
{{ $pressKits->links() }}
</div>

View file

@ -0,0 +1,734 @@
<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\Image\ImageService;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Firma')] class extends Component {
use WithFileUploads;
#[Locked]
public int $id;
public bool $showCompanyForm = false;
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 bool $showContactForm = false;
public ?int $editingContactId = null;
public string $contactFirstName = '';
public string $contactLastName = '';
public string $contactResponsibility = '';
public string $contactEmail = '';
public string $contactPhone = '';
public function mount(int $id): void
{
$this->id = $id;
$context = app(CustomerCompanyContext::class);
$company = $context->findFor(auth()->user(), $id);
abort_unless($company !== null, 404);
$context->select(auth()->user(), $id);
}
public function startEditCompany(): void
{
$company = $this->company();
$this->authorize('update', $company);
$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;
$this->companyLogo = null;
$this->removeCompanyLogo = false;
$this->showCompanyForm = true;
}
public function cancelCompanyForm(): void
{
$this->resetCompanyForm();
}
public function saveCompany(ImageService $imageService): void
{
$company = $this->company();
$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->resetCompanyForm();
session()->flash('company-status', __('Stammdaten wurden gespeichert.'));
}
public function startCreateContact(): void
{
$this->authorize('update', $this->company());
$this->resetContactForm();
$this->showContactForm = true;
}
public function editContact(int $contactId): void
{
$this->authorize('update', $this->company());
$contact = $this->contact($contactId);
$this->editingContactId = $contact->id;
$this->contactFirstName = (string) ($contact->first_name ?? '');
$this->contactLastName = (string) ($contact->last_name ?? '');
$this->contactResponsibility = (string) ($contact->responsibility ?? '');
$this->contactEmail = (string) ($contact->email ?? '');
$this->contactPhone = (string) ($contact->phone ?? '');
$this->showContactForm = true;
}
public function cancelContactForm(): void
{
$this->resetContactForm();
}
public function saveContact(): void
{
$company = $this->company();
$this->authorize('update', $company);
$validated = $this->validate([
'contactFirstName' => ['nullable', 'string', 'max:80'],
'contactLastName' => ['nullable', 'string', 'max:80'],
'contactResponsibility' => ['nullable', 'string', 'max:255'],
'contactEmail' => ['required', 'email', 'max:255'],
'contactPhone' => ['nullable', 'string', 'max:40'],
]);
if (blank($validated['contactFirstName']) && blank($validated['contactLastName'])) {
throw ValidationException::withMessages([
'contactLastName' => __('Bitte geben Sie mindestens einen Namen an.'),
]);
}
$payload = [
'company_id' => $company->id,
'portal' => $company->portal?->value,
'first_name' => $validated['contactFirstName'] ?: null,
'last_name' => $validated['contactLastName'] ?: null,
'responsibility' => $validated['contactResponsibility'] ?: null,
'email' => $validated['contactEmail'],
'phone' => $validated['contactPhone'] ?: null,
];
if ($this->editingContactId) {
$this->contact($this->editingContactId)->update($payload);
session()->flash('contact-status', __('Pressekontakt wurde aktualisiert.'));
} else {
Contact::query()->create($payload);
session()->flash('contact-status', __('Pressekontakt wurde angelegt.'));
}
$this->resetContactForm();
}
public function deleteContact(int $contactId): void
{
$this->authorize('update', $this->company());
$contact = $this->contact($contactId);
$contact->delete();
if ($this->editingContactId === $contactId) {
$this->resetContactForm();
}
session()->flash('contact-status', __('Pressekontakt wurde gelöscht.'));
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$company = $context
->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases'])
->findOrFail($this->id);
return [
'company' => $company,
'roleLabel' => $context->roleLabelFor($company, $user),
'canManageCompany' => $user->can('update', $company),
'canManageContacts' => $user->can('update', $company),
'countries' => (array) config('countries.items', []),
'contacts' => Contact::withoutGlobalScopes()
->where('company_id', $company->id)
->withCount('pressReleases')
->orderBy('last_name')
->orderBy('first_name')
->limit(10)
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']),
'pressReleases' => PressRelease::withoutGlobalScopes()
->where('user_id', $user->id)
->where('company_id', $company->id)
->latest()
->limit(10)
->get(['id', 'title', 'status', 'created_at', 'published_at']),
];
}
private function company(): Company
{
$company = app(CustomerCompanyContext::class)->findFor(auth()->user(), $this->id);
abort_unless($company !== null, 404);
return $company;
}
private function contact(int $contactId): Contact
{
return Contact::withoutGlobalScopes()->where('company_id', $this->id)->findOrFail($contactId);
}
private function resetCompanyForm(): void
{
$this->showCompanyForm = false;
$this->companyName = '';
$this->companyAddress = '';
$this->companyEmail = '';
$this->companyPhone = '';
$this->companyWebsite = '';
$this->companyCountryCode = 'DE';
$this->companyDisableFooterCode = false;
$this->companyLogo = null;
$this->removeCompanyLogo = false;
$this->resetValidation();
}
private function resetContactForm(): void
{
$this->showContactForm = false;
$this->editingContactId = null;
$this->contactFirstName = '';
$this->contactLastName = '';
$this->contactResponsibility = '';
$this->contactEmail = '';
$this->contactPhone = '';
$this->resetValidation();
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex items-start gap-4">
<div
class="flex size-16 shrink-0 items-center justify-center rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
@if ($company->logoUrl())
<img src="{{ $company->logoUrl() }}" alt="{{ $company->name }}" width="64" height="64"
class="h-20 max-h-20 w-20 max-w-20 rounded-xl object-contain p-2" />
@else
<flux:icon.building-office class="size-8 text-zinc-400" />
@endif
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<flux:heading size="xl">{{ $company->name }}</flux:heading>
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</div>
<flux:text class="mt-1 text-sm text-zinc-500">{{ $company->slug }}</flux:text>
<div class="mt-3 flex flex-wrap gap-2">
<flux:badge color="zinc" size="sm">
{{ $company->portal?->label() ?? __('Portal unbekannt') }}</flux:badge>
<flux:badge color="indigo" size="sm">{{ $roleLabel }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ __('Pressemappe') }}</flux:badge>
@if ($company->disable_footer_code)
<flux:badge color="amber" size="sm">{{ __('Footer-Code deaktiviert') }}</flux:badge>
@endif
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<flux:button icon="plus" variant="primary" href="{{ route('me.press-releases.create') }}"
wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
@if ($canManageCompany)
<flux:button icon="pencil" variant="ghost" wire:click="startEditCompany">
{{ __('Stammdaten bearbeiten') }}
</flux:button>
@endif
<flux:button icon="arrow-left" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</div>
</flux:card>
<flux:card>
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="ghost" href="#stammdaten">{{ __('Stammdaten') }}</flux:button>
<flux:button size="sm" variant="ghost" href="#pressekontakte">{{ __('Pressekontakte') }}</flux:button>
<flux:button size="sm" variant="ghost" href="#pressemitteilungen">{{ __('Pressemitteilungen') }}</flux:button>
<flux:button size="sm" variant="ghost" href="#abrechnung">{{ __('Abrechnung') }}</flux:button>
<flux:button size="sm" variant="ghost" href="#statistik">{{ __('Statistik') }}</flux:button>
</div>
</flux:card>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $company->press_releases_count }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $company->contacts_count }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Portal') }}</flux:text>
<flux:text weight="bold">{{ $company->portal?->label() ?? '' }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Deine Rolle') }}</flux:text>
<flux:text weight="bold">{{ $roleLabel }}</flux:text>
</flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<flux:card id="stammdaten">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Stammdaten') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">{{ __('Firmendaten dieser Firma.') }}</flux:text>
</div>
<div class="flex items-center gap-2">
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
@if ($canManageCompany)
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="startEditCompany">
{{ __('Bearbeiten') }}
</flux:button>
@endif
</div>
</div>
@if (session('company-status'))
<flux:callout color="green" icon="check-circle" class="mb-4">
{{ session('company-status') }}
</flux:callout>
@endif
@if ($showCompanyForm)
<div class="mb-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm" class="mb-4">
{{ __('Stammdaten bearbeiten') }}
</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="companyName" :label="__('Firmenname')" required />
<flux:error name="companyName" />
</flux:field>
<flux:field>
<flux:input wire:model="companyEmail" :label="__('E-Mail')" type="email" />
<flux:error name="companyEmail" />
</flux:field>
<flux:field>
<flux:input wire:model="companyPhone" :label="__('Telefon')" />
<flux:error name="companyPhone" />
</flux:field>
<flux:field>
<flux:input wire:model="companyWebsite" :label="__('Website')" placeholder="https://..." />
<flux:error name="companyWebsite" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="companyAddress" :label="__('Adresse')" rows="3" />
<flux:error name="companyAddress" />
</flux:field>
<flux:field>
<flux:select wire:model="companyCountryCode" :label="__('Land')">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
<flux:error name="companyCountryCode" />
</flux:field>
<flux:field>
<flux:checkbox wire:model="companyDisableFooterCode"
:label="__('Footer-Code deaktivieren')" />
</flux:field>
</div>
<flux:separator class="my-4" />
<div class="space-y-3">
<flux:heading size="xs">{{ __('Firmenlogo') }}</flux:heading>
@php($logoUrl = $company->logoUrl())
@if ($logoUrl && !$removeCompanyLogo)
<div class="flex items-center gap-3">
<img src="{{ $logoUrl }}" alt="{{ $company->name }}" width="64"
height="64"
class="h-16 max-h-16 w-16 max-w-16 rounded-md border border-zinc-200 object-contain dark:border-zinc-700" />
<flux:button type="button" size="sm" variant="ghost"
wire:click="$set('removeCompanyLogo', true)">
{{ __('Logo entfernen') }}
</flux:button>
</div>
@endif
<flux:field>
<flux:input type="file" wire:model="companyLogo" :label="__('Neues Logo hochladen')"
accept="image/jpeg,image/png,image/webp,image/gif"
:description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')" />
<flux:error name="companyLogo" />
</flux:field>
</div>
<div class="mt-4 flex justify-end gap-2">
<flux:button type="button" variant="ghost" wire:click="cancelCompanyForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveCompany">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
@endif
<div class="grid gap-3 sm:grid-cols-2">
<div>
<flux:text class="text-xs text-zinc-500">{{ __('E-Mail') }}</flux:text>
<flux:text>{{ $company->email ?: '' }}</flux:text>
</div>
<div>
<flux:text class="text-xs text-zinc-500">{{ __('Telefon') }}</flux:text>
<flux:text>{{ $company->phone ?: '' }}</flux:text>
</div>
<div>
<flux:text class="text-xs text-zinc-500">{{ __('Website') }}</flux:text>
@if ($company->website)
<a href="{{ $company->website }}" target="_blank"
class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ $company->website }}</a>
@else
<flux:text></flux:text>
@endif
</div>
<div>
<flux:text class="text-xs text-zinc-500">{{ __('Land') }}</flux:text>
<flux:text>{{ $company->country_code ?: '' }}</flux:text>
</div>
<div class="sm:col-span-2">
<flux:text class="text-xs text-zinc-500">{{ __('Adresse') }}</flux:text>
<flux:text>{{ $company->address ?: '' }}</flux:text>
</div>
</div>
</flux:card>
<flux:card id="pressekontakte">
<div class="mb-4 flex items-center justify-between">
<div>
<flux:heading size="lg">{{ __('Pressekontakte') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ trans_choice(':count Kontakt|:count Kontakte', $company->contacts_count, ['count' => $company->contacts_count]) }}
</flux:text>
</div>
@if ($canManageContacts)
<flux:button size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
{{ __('Kontakt hinzufügen') }}
</flux:button>
@endif
</div>
@if (session('contact-status'))
<flux:callout color="green" icon="check-circle" class="mb-4">
{{ session('contact-status') }}
</flux:callout>
@endif
@if ($showContactForm)
<div class="mb-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm" class="mb-4">
{{ $editingContactId ? __('Pressekontakt bearbeiten') : __('Neuen Pressekontakt anlegen') }}
</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="contactFirstName" :label="__('Vorname')" />
<flux:error name="contactFirstName" />
</flux:field>
<flux:field>
<flux:input wire:model="contactLastName" :label="__('Nachname')" />
<flux:error name="contactLastName" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:input wire:model="contactResponsibility" :label="__('Position / Rolle')" />
<flux:error name="contactResponsibility" />
</flux:field>
<flux:field>
<flux:input wire:model="contactEmail" :label="__('E-Mail')" type="email" required />
<flux:error name="contactEmail" />
</flux:field>
<flux:field>
<flux:input wire:model="contactPhone" :label="__('Telefon')" />
<flux:error name="contactPhone" />
</flux:field>
</div>
<div class="mt-4 flex justify-end gap-2">
<flux:button type="button" variant="ghost" wire:click="cancelContactForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveContact">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
@endif
<div class="space-y-3">
@forelse($contacts as $contact)
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:text weight="semibold">
{{ trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</flux:text>
<flux:text class="text-sm text-zinc-500">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-blue-600 hover:underline dark:text-blue-400">{{ $contact->email }}</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
@if ($contact->press_releases_count > 0)
<span>{{ trans_choice('in :count Pressemitteilung hinterlegt|in :count Pressemitteilungen hinterlegt', $contact->press_releases_count, ['count' => $contact->press_releases_count]) }}</span>
@endif
</div>
</div>
@if ($canManageContacts)
<div class="flex gap-1">
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="editContact({{ $contact->id }})">
{{ __('Bearbeiten') }}
</flux:button>
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="deleteContact({{ $contact->id }})"
wire:confirm="{{ __('Diesen Pressekontakt löschen?') }}">
{{ __('Löschen') }}
</flux:button>
</div>
@endif
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
<flux:text weight="semibold">{{ __('Keine Pressekontakte hinterlegt') }}</flux:text>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Pressekontakte helfen, Pressemitteilungen eindeutig einer Ansprechperson zuzuordnen.') }}
</flux:text>
@if ($canManageContacts)
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
{{ __('Kontakt hinzufügen') }}
</flux:button>
@endif
</div>
@endforelse
</div>
</flux:card>
</div>
<flux:card id="pressemitteilungen" class="p-0">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<flux:heading size="lg">{{ __('Pressemitteilungen dieser Firma') }}</flux:heading>
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
<div class="px-4 pb-4 pt-2">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Datum') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($pressReleases as $pressRelease)
<flux:table.row wire:key="company-pr-{{ $pressRelease->id }}">
<flux:table.cell>
<flux:text weight="semibold">{{ $pressRelease->title }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge
color="{{ match ($pressRelease->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
} }}"
size="sm">
{{ $pressRelease->status->label() }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">
{{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '') }}
</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="ghost" icon="eye"
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="4">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">
{{ __('Keine Pressemitteilungen für diese Firma') }}
</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Erstellen Sie die erste Pressemitteilung direkt mit dieser Firma als Kontext.') }}
</flux:text>
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</div>
</flux:card>
<div class="grid gap-6 xl:grid-cols-2">
<flux:card id="abrechnung">
<div class="flex items-start justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Abrechnung') }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Firmenspezifische Zahlungsarten und Add-ons werden hier später zusammengeführt.') }}
</flux:text>
</div>
<flux:badge color="zinc" size="sm">{{ __('In Vorbereitung') }}</flux:badge>
</div>
<div class="mt-4 rounded-lg border border-dashed border-zinc-200 p-4 dark:border-zinc-700">
<flux:text weight="semibold">{{ __('Noch keine firmenspezifische Abrechnung') }}</flux:text>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Rechnungen finden Sie aktuell gesammelt im Finanzbereich. Firmenscharfe Zahlungsarten folgen mit dem Preismodell.') }}
</flux:text>
<flux:button class="mt-4" size="sm" variant="ghost" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen öffnen') }}
</flux:button>
</div>
</flux:card>
<flux:card id="statistik">
<div class="flex items-start justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Statistik') }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Erste Kennzahlen zur Firma; detaillierte Auswertungen folgen später.') }}
</flux:text>
</div>
<flux:badge color="zinc" size="sm">{{ __('Später') }}</flux:badge>
</div>
<div class="mt-4 grid grid-cols-2 gap-3">
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
<flux:text size="lg" weight="bold">{{ $company->press_releases_count }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
<flux:text size="lg" weight="bold">{{ $company->contacts_count }}</flux:text>
</div>
</div>
</flux:card>
</div>
</div>

View file

@ -0,0 +1,227 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
{
public string $portal = 'presseecho';
public string $language = 'de';
public int|string|null $companyId = null;
public int|string|null $categoryId = null;
public string $title = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public function mount(): void
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$firstCompany = $context->selectedCompany($user) ?? $context->companiesFor($user)->first();
if ($firstCompany) {
$this->companyId = $firstCompany->id;
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
}
}
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')],
'title' => ['required', 'string', 'min:5', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
]);
$user = auth()->user();
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
return;
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft;
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
$pr = PressRelease::query()->create([
'uuid' => (string) Str::uuid(),
'portal' => $this->portal,
'language' => $this->language,
'user_id' => $user->id,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'slug' => $slug,
'text' => $this->text,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'status' => $status->value,
]);
session()->flash('success', $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Entwurf gespeichert.'));
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$myCompanies = $context->companiesFor($user);
$categories = Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get();
return [
'myCompanies' => $myCompanies,
'categories' => $categories,
'selectedPortalLabel' => $this->selectedCompany()?->portal?->label() ?? __('Wird aus der Firma übernommen'),
];
}
public function updatedCompanyId(): void
{
$company = $this->selectedCompany();
if ($company?->portal) {
$this->portal = $company->portal->value;
}
}
private function selectedCompany(): ?Company
{
return app(CustomerCompanyContext::class)
->findFor(auth()->user(), (int) $this->companyId);
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Neue Pressemitteilung') }}</flux:heading>
<flux:subheading>{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text…') }}" />
<flux:error name="text" />
</flux:field>
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
</flux:field>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Firma') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="companyId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<flux:input :label="__('Portal')" :value="$selectedPortalLabel" disabled />
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
</div>
</flux:card>
<flux:card>
<div class="space-y-2">
<flux:button type="button" variant="primary" class="w-full" wire:click="save('review')">
{{ __('Zur Prüfung einreichen') }}
</flux:button>
<flux:button type="button" variant="ghost" class="w-full" wire:click="save('draft')">
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</flux:card>
</div>
</div>
</div>

View file

@ -0,0 +1,231 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $portal = '';
public string $language = 'de';
public int|string|null $companyId = null;
public int|string|null $categoryId = null;
public string $title = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('update', $pr);
abort_unless(
in_array($pr->status->value, ['draft', 'rejected']),
403,
__('Nur Entwürfe und abgelehnte Pressemitteilungen können bearbeitet werden.')
);
$this->portal = $pr->portal->value;
$this->language = $pr->language;
$this->companyId = $pr->company_id;
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->text = $pr->text;
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
}
public function updatedCompanyId(): void
{
$company = $this->selectedCompany();
if ($company?->portal) {
$this->portal = $company->portal->value;
}
}
public function save(): void
{
$this->validate([
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'title' => ['required', 'string', 'min:5', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
]);
$pr = $this->getMyPR();
$this->authorize('update', $pr);
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
return;
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$pr->update([
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'text' => $this->text,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
]);
session()->flash('success', __('Pressemitteilung gespeichert.'));
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
public function with(): array
{
$user = auth()->user();
$myCompanies = $user->companies()->orderBy('name')->get(['companies.id', 'companies.name', 'companies.portal']);
$selectedCompany = $this->selectedCompany();
$categories = Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get();
return [
'myCompanies' => $myCompanies,
'categories' => $categories,
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
];
}
private function getMyPR(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->findOrFail($this->id);
}
private function selectedCompany(): ?Company
{
return auth()->user()
->companies()
->whereKey((int) $this->companyId)
->first(['companies.id', 'companies.portal']);
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
<flux:subheading>{{ __('Inhalt und Metadaten der Pressemitteilung aktualisieren.') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="title" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:error name="text" />
</flux:field>
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" />
</flux:field>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('Firma') }}</flux:label>
<flux:select wire:model="companyId">
@foreach($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<flux:error name="companyId" />
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }}</flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
<flux:error name="categoryId" />
</flux:field>
<flux:input :label="__('Portal')" :value="$selectedPortalLabel" disabled />
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
<flux:select wire:model="language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</flux:field>
</div>
</flux:card>
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
</div>

View file

@ -0,0 +1,201 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\Customer\CustomerCompanyContext;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $statusFilter = 'all';
#[Url(as: 'company', except: 'all')]
public string $companyFilter = 'all';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
$this->resetPage();
}
public function updatedSearch(): void { $this->resetPage(); }
public function updatedStatusFilter(): void { $this->resetPage(); }
public function updatedCompanyFilter(): void { $this->resetPage(); }
public function submitForReview(int $id): void
{
$pr = $this->findMyPR($id);
if (! $pr) { return; }
try {
app(PressReleaseService::class)->submitForReview($pr);
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
} catch (\LogicException $e) {
session()->flash('error', $e->getMessage());
}
}
public function with(): array
{
$userId = auth()->id();
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
$prs = PressRelease::withoutGlobalScopes()
->where('user_id', $userId)
->with('company:id,name')
->when($selectedCompanyId !== null, fn ($q) => $q->where('company_id', $selectedCompanyId))
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn ($q) => $q->whereNotNull('company_id'))
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn ($q) => $q->whereNull('company_id'))
->when(filled($this->search), function ($q): void {
$term = $this->search;
$q->where('title', 'like', '%'.$term.'%');
})
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
->orderBy(in_array($this->sortBy, ['title', 'status', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
->paginate(100);
return [
'pressReleases' => $prs,
'statusOptions' => PressReleaseStatus::cases(),
'selectedCompany' => $context->selectedCompany(auth()->user()),
'hasGlobalCompanyContext' => $selectedCompanyId === null,
];
}
private function findMyPR(int $id): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
->where('id', $id)
->where('user_id', auth()->id())
->first();
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Meine Pressemitteilungen') }}</flux:heading>
@if($selectedCompany)
<flux:subheading>{{ __('Gefiltert auf :company', ['company' => $selectedCompany->name]) }}</flux:subheading>
@endif
</div>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</flux:card>
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Titel suchen…') }}" icon="magnifying-glass" class="flex-1" />
<flux:select wire:model.live="statusFilter" class="sm:w-44">
<option value="all">{{ __('Alle Status') }}</option>
@foreach($statusOptions as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</flux:select>
@if($hasGlobalCompanyContext)
<flux:select wire:model.live="companyFilter" class="sm:w-48">
<option value="all">{{ __('Alle Firmenzuordnungen') }}</option>
<option value="assigned">{{ __('Mit Firma') }}</option>
<option value="unassigned">{{ __('Ohne Firma') }}</option>
</flux:select>
@endif
</div>
</flux:card>
<flux:card class="p-0">
<div class="p-4">
<flux:table>
<flux:table.columns>
<flux:table.column sortable :sorted="$sortBy==='title'" :direction="$sortDir" wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Firma') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy==='status'" :direction="$sortDir" wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy==='created_at'" :direction="$sortDir" wire:click="sort('created_at')">{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($pressReleases as $pr)
<flux:table.row wire:key="{{ $pr->id }}">
<flux:table.cell>
<p class="max-w-xs truncate font-medium">{{ $pr->title }}</p>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ $pr->company?->name ?? '' }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
} }}">{{ $pr->status->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y') }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="eye" href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
@if(in_array($pr->status->value, ['draft', 'rejected']))
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
<flux:button size="sm" variant="ghost" icon="paper-airplane" wire:click="submitForReview({{ $pr->id }})"
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}" />
@endif
</div>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine Pressemitteilungen gefunden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Passen Sie die Filter an oder erstellen Sie eine neue Pressemitteilung.') }}
</flux:text>
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</div>
</flux:card>
{{ $pressReleases->links() }}
</div>

View file

@ -0,0 +1,323 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
#[Locked]
public int $id;
public ?string $shareUrl = null;
public ?string $shareExpiresAt = null;
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('view', $pr);
}
public function submitForReview(): void
{
$pr = $this->getMyPR();
$this->authorize('submitForReview', $pr);
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
return;
}
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
}
public function generateShareLink(MagicLinkGenerator $generator): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$share = $generator->createPressReleaseShareLink($pr, auth()->user());
$this->shareUrl = $share['url'];
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
session()->flash('success', __('Vorschau-Link wurde erzeugt.'));
}
public function with(): array
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '';
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
return [
'pr' => $pr,
'categoryName' => $categoryName,
'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']),
'latestRejection' => $latestRejection,
'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs,
'statusColor' => match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
},
];
}
private function getMyPR(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->with([
'company:id,name,email,phone',
'category.translations',
'contacts' => fn ($query) => $query
->withoutGlobalScopes()
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name,email',
])
->findOrFail($this->id);
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ strtoupper($pr->language) }}</flux:badge>
</div>
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ $pr->company?->name ?? '' }} · {{ $categoryName }} · {{ $pr->created_at->format('d.m.Y') }}
</flux:text>
</div>
<div class="flex gap-2">
@if($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</div>
@if($shareUrl)
<div class="mt-4 rounded-md border border-emerald-300 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
<flux:heading size="sm" class="mb-2">{{ __('Öffentlicher Vorschau-Link erstellt') }}</flux:heading>
<flux:text class="mb-2 text-xs text-zinc-500">{{ __('Gültig bis :date.', ['date' => $shareExpiresAt]) }}</flux:text>
<flux:input readonly :value="$shareUrl" />
</div>
@endif
</flux:card>
@if($pr->status === PressReleaseStatus::Rejected && $latestRejection)
<flux:callout color="red" icon="exclamation-triangle">
<flux:callout.heading>{{ __('Diese Pressemitteilung wurde abgelehnt') }}</flux:callout.heading>
<flux:callout.text>
@if($latestRejection->reason)
<strong>{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
@endif
<span class="mt-2 block text-xs text-red-700/70 dark:text-red-300/70">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
</flux:callout.text>
</flux:callout>
@endif
@if($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<flux:card>
<div class="flex flex-wrap items-center justify-between gap-3">
<flux:text class="text-sm text-zinc-500">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
</flux:text>
<div class="flex items-center gap-2">
@if($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button type="button" variant="primary" wire:click="submitForReview"
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
</flux:button>
</div>
</div>
</flux:card>
@endif
@if($pr->status === PressReleaseStatus::Review)
<flux:callout color="yellow" icon="clock">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</flux:callout>
@endif
<div class="grid gap-6 xl:grid-cols-2">
<flux:card>
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Zugeordnete Pressekontakte') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</flux:text>
</div>
@if($pr->company)
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
</div>
<div class="space-y-3">
@forelse($contacts as $contact)
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text weight="semibold">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500">
@if($contact->email)
<a href="mailto:{{ $contact->email }}" class="text-blue-600 hover:underline dark:text-blue-400">{{ $contact->email }}</a>
@endif
@if($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
@if($pr->company)
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate class="font-medium text-blue-600 hover:underline dark:text-blue-400">
{{ __('Kontakte in der Firma prüfen.') }}
</a>
@endif
</div>
@endforelse
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Status & Verlauf') }}</flux:heading>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Aktueller Status') }}</flux:text>
<flux:badge class="mt-1" :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Erstellt') }}</flux:text>
<flux:text weight="semibold">{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Veröffentlicht') }}</flux:text>
<flux:text weight="semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Aufrufe') }}</flux:text>
<flux:text weight="semibold">{{ number_format($pr->hits, 0, ',', '.') }}</flux:text>
</div>
</div>
<flux:separator class="my-4" />
@if($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-zinc-200 ps-4 dark:border-zinc-700">
@foreach($statusLogs as $log)
<li class="text-sm">
<div class="flex flex-wrap items-center gap-2">
@php
$color = match($log->to_status?->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
};
@endphp
<flux:badge size="sm" :color="$color">{{ $log->to_status?->label() }}</flux:badge>
<span class="text-xs text-zinc-500">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if($log->changedBy)
<span class="text-xs text-zinc-500">
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
</span>
@endif
</div>
@if($log->reason)
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<flux:text class="text-sm text-zinc-500">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</flux:text>
@endif
</flux:card>
</div>
<flux:card>
<div class="prose prose-zinc dark:prose-invert max-w-none">
{!! nl2br(e($pr->text)) !!}
</div>
@if($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-zinc-200 pt-4 text-sm text-zinc-500 dark:border-zinc-700">
@if($pr->keywords)
<p><strong>{{ __('Stichwörter') }}:</strong> {{ $pr->keywords }}</p>
@endif
@if($pr->backlink_url)
<p><strong>{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline">{{ $pr->backlink_url }}</a>
</p>
@endif
</div>
@endif
</flux:card>
</div>

View file

@ -0,0 +1,451 @@
<?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';
public string $salutationKey = 'none';
public string $firstName = '';
public string $lastName = '';
public string $title = '';
public string $phone = '';
public string $address = '';
public string $countryCode = 'DE';
public string $backlinkUrl = '';
public bool $showStats = false;
public bool $disableFooterCode = false;
public string $taxIdNumber = '';
public string $billingName = '';
public string $billingAddress1 = '';
public string $billingAddress2 = '';
public string $billingPostalCode = '';
public string $billingCity = '';
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();
$profile = $user->profile;
$this->name = (string) $user->name;
$this->language = $user->language ?? 'de';
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
$this->firstName = (string) ($profile?->first_name ?? '');
$this->lastName = (string) ($profile?->last_name ?? '');
$this->title = (string) ($profile?->title ?? '');
$this->phone = (string) ($profile?->phone ?? '');
$this->address = (string) ($profile?->address ?? '');
$this->countryCode = (string) ($profile?->country_code ?? 'DE');
$this->backlinkUrl = (string) ($profile?->backlink_url ?? '');
$this->showStats = (bool) ($profile?->show_stats ?? false);
$this->disableFooterCode = (bool) ($profile?->disable_footer_code ?? false);
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
$billingAddress = $user->billingAddress;
$this->billingName = (string) ($billingAddress?->name ?? $user->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
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:120'],
'language' => ['required', Rule::in(['de', 'en'])],
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
'firstName' => ['nullable', 'string', 'max:80'],
'lastName' => ['nullable', 'string', 'max:80'],
'title' => ['nullable', 'string', 'max:80'],
'phone' => ['nullable', 'string', 'max:40'],
'address' => ['nullable', 'string', 'max:1000'],
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'taxIdNumber' => ['nullable', 'string', 'max:255'],
'billingName' => ['nullable', 'string', 'max:255'],
'billingAddress1' => ['nullable', 'string', 'max:255'],
'billingAddress2' => ['nullable', 'string', 'max:255'],
'billingPostalCode' => ['nullable', 'string', 'max:20'],
'billingCity' => ['nullable', 'string', 'max:120'],
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
]);
if ($this->billingHasInput() && ! $this->billingIsComplete()) {
throw ValidationException::withMessages([
'billingName' => __('Bitte füllen Sie für eine Rechnungsadresse Name, Adresse, PLZ, Ort und Land aus.'),
]);
}
/** @var User $user */
$user = auth()->user();
$user->forceFill([
'name' => $validated['name'],
'language' => $validated['language'],
])->save();
$user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'],
'first_name' => $validated['firstName'] ?: null,
'last_name' => $validated['lastName'] ?: null,
'title' => $validated['title'] ?: null,
'phone' => $validated['phone'] ?: null,
'address' => $validated['address'] ?: null,
'country_code' => $validated['countryCode'] ?: null,
'backlink_url' => $validated['backlinkUrl'] ?: null,
'show_stats' => $this->showStats,
'disable_footer_code' => $this->disableFooterCode,
'tax_id_number' => $validated['taxIdNumber'] ?: null,
]
);
if (! $this->billingHasInput()) {
$user->billingAddress()->delete();
} else {
$user->billingAddress()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'] !== 'none' ? $validated['salutationKey'] : null,
'title' => $validated['title'] ?: null,
'name' => $validated['billingName'],
'address1' => $validated['billingAddress1'],
'address2' => $validated['billingAddress2'] ?: null,
'postal_code' => $validated['billingPostalCode'],
'city' => $validated['billingCity'],
'country_code' => $validated['billingCountryCode'],
],
);
}
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,
];
}
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)
|| filled($this->billingAddress1)
|| filled($this->billingAddress2)
|| filled($this->billingPostalCode)
|| filled($this->billingCity);
}
public function billingIsComplete(): bool
{
return filled($this->billingName)
&& filled($this->billingAddress1)
&& filled($this->billingPostalCode)
&& filled($this->billingCity)
&& filled($this->billingCountryCode);
}
}; ?>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Mein Profil') }}</flux:heading>
<flux:subheading>
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
</flux:subheading>
</flux:card>
@if(session('profile-status'))
<flux:callout color="green" icon="check-circle">{{ session('profile-status') }}</flux:callout>
@endif
<form wire:submit="saveProfile" class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Konto') }}</flux:heading>
<div class="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>
</flux:card>
<flux:card id="profil">
<div class="mb-4 flex flex-wrap gap-2">
<flux:badge color="indigo" size="sm">{{ __('Profil') }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ __('Rechnungsadresse') }}</flux:badge>
</div>
<flux:heading size="sm" class="mb-4">{{ __('Profil') }}</flux:heading>
<div class="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>
<flux:separator class="my-6" />
<flux:heading id="rechnungsadresse" size="sm" class="mb-2">{{ __('Rechnungsadresse') }}</flux:heading>
<flux:text class="mb-4 text-sm text-zinc-500">
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
</flux:text>
@if(! $this->billingIsComplete())
<flux:callout color="amber" icon="exclamation-triangle" class="mb-4">
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
</flux:callout>
@endif
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress1" :label="__('Adresse Zeile 1')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress2" :label="__('Adresse Zeile 2')" class="sm:col-span-2" />
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
<flux:input wire:model="billingCity" :label="__('Ort')" />
<flux:select wire:model="billingCountryCode" :label="__('Land')">
@foreach($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="taxIdNumber" :label="__('USt-ID')" />
<flux:error name="billingName" class="sm:col-span-2" />
</div>
</flux:card>
<div class="lg:col-span-2 flex justify-end">
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
</div>
</form>
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Zugeordnete Firmen') }}</flux:heading>
@forelse($companies as $company)
<div class="flex flex-col gap-2 border-b border-zinc-100 py-3 last:border-0 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="font-medium text-sm">{{ $company->name }}</p>
<div class="flex flex-wrap items-center gap-2">
<flux:badge color="zinc" size="sm">{{ $company->portal?->label() ?? '' }}</flux:badge>
<flux:badge color="indigo" size="sm">{{ $company->pivot->role ?? 'member' }}</flux:badge>
@if($company->owner_user_id === $user->id)
<flux:badge color="green" size="sm">{{ __('Eigentümer') }}</flux:badge>
@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
<flux:text class="text-sm text-zinc-500">
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
</flux:text>
@endforelse
</flux:card>
</div>

View file

@ -0,0 +1,295 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Laravel\Fortify\Features;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
{
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public string $email = '';
public bool $confirmedTwoFactor = false;
public function mount(): void
{
$user = auth()->user();
$this->email = (string) $user->email;
$this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null);
}
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->forceFill([
'password' => Hash::make($validated['password']),
])->save();
$this->reset('current_password', 'password', 'password_confirmation');
session()->flash('security-status', __('Passwort aktualisiert.'));
}
public function updateEmail(): void
{
$validated = $this->validate([
'email' => [
'required',
'email',
'max:190',
Rule::unique(User::class, 'email')->ignore(auth()->id()),
],
]);
/** @var User $user */
$user = auth()->user();
$user->forceFill([
'email' => $validated['email'],
'email_verified_at' => null,
])->save();
if (Features::enabled(Features::emailVerification())) {
$user->sendEmailVerificationNotification();
}
session()->flash('security-status', __('E-Mail-Adresse aktualisiert. Bitte erneut bestätigen, falls eine Verifizierung verschickt wurde.'));
}
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
{
$enable(auth()->user());
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung aktiviert. Scannen Sie den QR-Code mit Ihrer Authenticator-App.'));
}
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void
{
$disable(auth()->user());
$this->confirmedTwoFactor = false;
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
}
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
{
$generate(auth()->user());
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt.'));
}
public function with(): array
{
/** @var User $user */
$user = auth()->user();
$user->refresh();
$qrUrl = null;
$recoveryCodes = [];
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
try {
$qrUrl = $user->twoFactorQrCodeSvg();
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [];
} catch (\Throwable) {
$qrUrl = null;
$recoveryCodes = [];
}
}
return [
'user' => $user,
'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null),
'twoFactorQrSvg' => $qrUrl,
'recoveryCodes' => $recoveryCodes,
'sessions' => DB::table('sessions')
->where('user_id', $user->id)
->orderByDesc('last_activity')
->limit(5)
->get(['id', 'ip_address', 'user_agent', 'last_activity']),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Konto-Sicherheit') }}</flux:heading>
<flux:subheading>
{{ __('Passwort, E-Mail und Zwei-Faktor-Authentifizierung verwalten.') }}
</flux:subheading>
</flux:card>
@if(session('security-status'))
<flux:callout color="green" icon="check-circle">{{ session('security-status') }}</flux:callout>
@endif
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('E-Mail') }}</flux:text>
<flux:text weight="bold" class="mt-1 truncate">{{ $user->email }}</flux:text>
<flux:badge class="mt-3" color="{{ $user->email_verified_at ? 'green' : 'amber' }}" size="sm">
{{ $user->email_verified_at ? __('Bestätigt') : __('Nicht bestätigt') }}
</flux:badge>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Zwei-Faktor') }}</flux:text>
<flux:text weight="bold" class="mt-1">
{{ $twoFactorEnabled ? __('Aktiv') : __('Nicht aktiv') }}
</flux:text>
<flux:badge class="mt-3" color="{{ $twoFactorEnabled ? 'green' : 'zinc' }}" size="sm">
{{ $twoFactorEnabled ? __('Zusatzschutz aktiv') : __('Empfohlen') }}
</flux:badge>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Letzter Login') }}</flux:text>
<flux:text weight="bold" class="mt-1">
{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Unbekannt') }}
</flux:text>
<flux:text class="mt-3 text-xs text-zinc-500">
{{ $user->last_login_ip ?: __('Keine IP gespeichert') }}
</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Aktive Sessions') }}</flux:text>
<flux:text weight="bold" class="mt-1">{{ $sessions->count() }}</flux:text>
<flux:text class="mt-3 text-xs text-zinc-500">
{{ __('Aus den aktuellen Web-Sessions') }}
</flux:text>
</flux:card>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Passwort ändern') }}</flux:heading>
<form wire:submit="updatePassword" class="space-y-4">
<flux:input wire:model="current_password" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
<flux:input wire:model="password" type="password" :label="__('Neues Passwort')" autocomplete="new-password" required />
<flux:input wire:model="password_confirmation" type="password" :label="__('Neues Passwort bestätigen')" autocomplete="new-password" required />
<div class="flex justify-end">
<flux:button type="submit" variant="primary">{{ __('Passwort speichern') }}</flux:button>
</div>
</form>
</flux:card>
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('E-Mail-Adresse ändern') }}</flux:heading>
<form wire:submit="updateEmail" class="space-y-4">
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
<flux:text class="text-xs text-zinc-500">
{{ __('Nach der Änderung kann eine erneute Bestätigung der E-Mail-Adresse erforderlich sein.') }}
</flux:text>
<div class="flex justify-end">
<flux:button type="submit" variant="primary">{{ __('E-Mail speichern') }}</flux:button>
</div>
</form>
</flux:card>
</div>
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Zwei-Faktor-Authentifizierung') }}</flux:heading>
@if(! $twoFactorEnabled)
<flux:text class="text-sm text-zinc-500">
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
</flux:text>
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
</flux:button>
@else
@if($twoFactorQrSvg)
<div class="flex flex-col gap-4 lg:flex-row lg:items-start">
<div class="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
{!! $twoFactorQrSvg !!}
</div>
<div class="space-y-3">
<flux:text class="text-sm">
{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }}
</flux:text>
@if(! empty($recoveryCodes))
<flux:heading size="xs">{{ __('Wiederherstellungs-Codes') }}</flux:heading>
<ul class="grid grid-cols-2 gap-2 text-xs font-mono">
@foreach($recoveryCodes as $code)
<li class="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{{ $code }}</li>
@endforeach
</ul>
@endif
</div>
</div>
@endif
<div class="mt-4 flex flex-wrap gap-2">
<flux:button wire:click="regenerateRecoveryCodes" variant="ghost">
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
{{ __('Zwei-Faktor deaktivieren') }}
</flux:button>
</div>
@endif
</flux:card>
<flux:card class="p-0">
<div class="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Aktive Sessions') }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Hier sehen Sie die letzten bekannten Web-Sessions Ihres Kontos. Abmelden erfolgt aktuell über das Nutzer-Menü.') }}
</flux:text>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($sessions as $session)
<div class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<flux:text weight="semibold">
{{ $session->ip_address ?: __('IP unbekannt') }}
</flux:text>
<flux:text class="mt-1 truncate text-xs text-zinc-500">
{{ Str::limit($session->user_agent ?: __('User-Agent unbekannt'), 120) }}
</flux:text>
</div>
<flux:badge color="zinc" size="sm">
{{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }}
</flux:badge>
</div>
@empty
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.shield-check class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine Sessions gefunden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Sobald Sessions protokolliert werden, erscheinen sie hier.') }}
</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,212 @@
<?php
use App\Services\Api\ApiAccessEligibilityService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Component
{
public string $tokenName = '';
/** @var list<string> */
public array $selectedAbilities = ['press-releases:read'];
public ?string $plainTextToken = null;
public ?string $notification = null;
public ?string $eligibilityMessage = null;
private const ABILITIES = [
'press-releases:read' => 'Pressemitteilungen lesen',
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
'press-release-images:write' => 'Bilder zu Pressemitteilungen verwalten',
'companies:read' => 'Firmendaten lesen',
'newsletter:subscribe' => 'Newsletter-Anmeldungen auslösen',
];
public function createToken(): void
{
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
if ($denialReason !== null) {
$this->plainTextToken = null;
$this->eligibilityMessage = __($denialReason);
return;
}
$validated = $this->validate([
'tokenName' => ['required', 'string', 'max:80'],
'selectedAbilities' => ['required', 'array', 'min:1'],
'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))],
]);
$token = auth()->user()->createToken(
$validated['tokenName'],
$validated['selectedAbilities'],
);
$this->plainTextToken = $token->plainTextToken;
$this->notification = __('Token wurde erstellt. Bitte kopieren Sie ihn jetzt, er wird später nicht erneut angezeigt.');
$this->eligibilityMessage = null;
$this->tokenName = '';
$this->selectedAbilities = ['press-releases:read'];
}
public function revokeToken(int $tokenId): void
{
auth()->user()
->tokens()
->whereKey($tokenId)
->delete();
$this->plainTextToken = null;
$this->notification = __('Token wurde widerrufen.');
}
public function with(): array
{
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
return [
'abilityOptions' => self::ABILITIES,
'canCreateApiToken' => $denialReason === null,
'apiTokenDenialReason' => $denialReason,
'tokens' => auth()->user()
->tokens()
->latest()
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('API-Tokens') }}</flux:heading>
<flux:subheading>{{ __('Erstellen und widerrufen Sie persönliche Tokens für die neue API v1.') }}</flux:subheading>
</div>
<flux:button href="{{ route('docs.api.v1') }}" variant="subtle">
{{ __('API-Dokumentation') }}
</flux:button>
</div>
</flux:card>
@if($notification)
<flux:callout color="green" icon="check-circle">
{{ $notification }}
</flux:callout>
@endif
@if($eligibilityMessage || $apiTokenDenialReason)
<flux:callout color="yellow" icon="lock-closed">
{{ $eligibilityMessage ?? $apiTokenDenialReason }}
</flux:callout>
@endif
@if($plainTextToken)
<flux:callout color="yellow" icon="key">
<div class="space-y-3">
<flux:text weight="semibold">{{ __('Neuer Token') }}</flux:text>
<code class="block overflow-x-auto rounded-md bg-zinc-950 px-3 py-2 text-sm text-white">{{ $plainTextToken }}</code>
</div>
</flux:callout>
@endif
<form wire:submit="createToken">
<flux:card class="space-y-5">
<div>
<flux:heading size="sm">{{ __('Neuen Token erstellen') }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Wählen Sie nur die Berechtigungen aus, die der jeweilige API-Client wirklich benötigt.') }}
</flux:text>
</div>
<flux:field>
<flux:label>{{ __('Name') }}</flux:label>
<flux:input wire:model="tokenName" placeholder="{{ __('z.B. Website-Integration') }}" />
<flux:error name="tokenName" />
</flux:field>
<div>
<flux:label>{{ __('Berechtigungen') }}</flux:label>
<div class="mt-3 grid gap-3 md:grid-cols-2">
@foreach($abilityOptions as $ability => $label)
<flux:checkbox wire:model="selectedAbilities" value="{{ $ability }}" label="{{ $label }}" />
@endforeach
</div>
<flux:error name="selectedAbilities" class="mt-3" />
</div>
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="key" :disabled="! $canCreateApiToken">
{{ __('Token erstellen') }}
</flux:button>
</div>
</flux:card>
</form>
<flux:card class="p-0">
<div class="p-4">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Berechtigungen') }}</flux:table.column>
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Zuletzt genutzt') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($tokens as $token)
<flux:table.row wire:key="token-{{ $token->id }}">
<flux:table.cell>
<flux:text weight="semibold">{{ $token->name }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<div class="flex flex-wrap gap-1">
@foreach($token->abilities ?? [] as $ability)
<flux:badge size="sm" color="zinc">{{ $ability }}</flux:badge>
@endforeach
</div>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $token->created_at?->format('d.m.Y H:i') }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $token->last_used_at?->format('d.m.Y H:i') ?? __('Nie') }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:button
size="sm"
variant="danger"
icon="trash"
wire:click="revokeToken({{ $token->id }})"
wire:confirm="{{ __('Diesen API-Token wirklich widerrufen?') }}"
>
{{ __('Widerrufen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.key class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine API-Tokens vorhanden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Erstellen Sie erst dann einen Token, wenn eine konkrete API-Integration ihn benötigt.') }}
</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</div>
</flux:card>
</div>