12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
52
resources/views/livewire/customer/bookings.blade.php
Normal file
52
resources/views/livewire/customer/bookings.blade.php
Normal 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>
|
||||
91
resources/views/livewire/customer/company-switcher.blade.php
Normal file
91
resources/views/livewire/customer/company-switcher.blade.php
Normal 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>
|
||||
237
resources/views/livewire/customer/dashboard.blade.php
Normal file
237
resources/views/livewire/customer/dashboard.blade.php
Normal 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') }} →
|
||||
</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>
|
||||
194
resources/views/livewire/customer/invoices.blade.php
Normal file
194
resources/views/livewire/customer/invoices.blade.php
Normal 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>
|
||||
119
resources/views/livewire/customer/press-kits/index.blade.php
Normal file
119
resources/views/livewire/customer/press-kits/index.blade.php
Normal 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>
|
||||
734
resources/views/livewire/customer/press-kits/show.blade.php
Normal file
734
resources/views/livewire/customer/press-kits/show.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
231
resources/views/livewire/customer/press-releases/edit.blade.php
Normal file
231
resources/views/livewire/customer/press-releases/edit.blade.php
Normal 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>
|
||||
201
resources/views/livewire/customer/press-releases/index.blade.php
Normal file
201
resources/views/livewire/customer/press-releases/index.blade.php
Normal 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>
|
||||
323
resources/views/livewire/customer/press-releases/show.blade.php
Normal file
323
resources/views/livewire/customer/press-releases/show.blade.php
Normal 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>
|
||||
451
resources/views/livewire/customer/profile.blade.php
Normal file
451
resources/views/livewire/customer/profile.blade.php
Normal 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>
|
||||
295
resources/views/livewire/customer/security.blade.php
Normal file
295
resources/views/livewire/customer/security.blade.php
Normal 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>
|
||||
212
resources/views/livewire/customer/tokens.blade.php
Normal file
212
resources/views/livewire/customer/tokens.blade.php
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue