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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,167 @@
<?php
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends Component
{
#[Locked]
public int $id;
public string $name = '';
public array $permissions = [];
public string $guardName = 'web';
public bool $isSystemRole = false;
public function mount(int $id): void
{
$this->id = $id;
$role = Role::query()
->with('permissions:id,name,guard_name')
->findOrFail($id);
$this->name = $role->name;
$this->guardName = $role->guard_name;
$this->permissions = $role->permissions
->pluck('name')
->values()
->all();
$this->isSystemRole = in_array($role->name, ['admin', 'editor', 'customer', 'api-only'], true);
}
public function save(): void
{
$validated = $this->validate([
'name' => [
'required',
'min:3',
'max:50',
Rule::unique('roles', 'name')
->ignore($this->id)
->where(fn (Builder $query) => $query->where('guard_name', $this->guardName)),
],
'permissions' => ['array'],
'permissions.*' => [
'string',
Rule::exists('permissions', 'name')
->where(fn (Builder $query) => $query->where('guard_name', $this->guardName)),
],
]);
$role = Role::query()->findOrFail($this->id);
$role->name = $validated['name'];
$role->save();
$role->syncPermissions($validated['permissions'] ?? []);
session()->flash('success', 'Rolle und Berechtigungen erfolgreich aktualisiert.');
$this->redirect(route('admin.roles.index'), navigate: true);
}
public function with(): array
{
return [
'permissionGroups' => $this->permissionGroups(),
];
}
private function permissionGroups(): Collection
{
$cache = app(AdminPerformanceCache::class);
return $cache->remember($cache->permissionGroupsKey($this->guardName), AdminPerformanceCache::OptionsTtl, fn () => Permission::query()
->where('guard_name', $this->guardName)
->orderBy('name')
->get(['name'])
->groupBy(function (Permission $permission): string {
$prefix = Str::contains($permission->name, ':')
? Str::before($permission->name, ':')
: $permission->name;
return Str::headline(str_replace(['-', '_'], ' ', $prefix));
})
->map(fn ($group) => $group->values())
->sortKeys());
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="lg">{{ __('Rolle bearbeiten') }}</flux:heading>
<flux:subheading>{{ __('ID') }}: {{ $id }} | {{ __('Guard') }}: {{ $guardName }}</flux:subheading>
</div>
@if($isSystemRole)
<flux:badge color="purple" size="sm">{{ __('Systemrolle') }}</flux:badge>
@endif
</div>
</flux:card>
@if($isSystemRole)
<flux:card>
<flux:text class="text-sm text-zinc-600 dark:text-zinc-300">
{{ __('Hinweis: Diese Rolle ist Teil des Basis-Setups. Aenderungen wirken sich direkt auf den Admin-Zugriff aus.') }}
</flux:text>
</flux:card>
@endif
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basis-Informationen') }}</flux:heading>
<flux:field>
<flux:label>{{ __('Technischer Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" />
<flux:error name="name" />
</flux:field>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Berechtigungen') }}</flux:heading>
<div class="space-y-6">
@forelse($permissionGroups as $groupName => $permissionsInGroup)
<div>
<flux:heading size="md" class="mb-3">{{ $groupName }}</flux:heading>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach($permissionsInGroup as $permission)
<flux:checkbox
wire:model="permissions"
value="{{ $permission['name'] }}"
label="{{ $permission['name'] }}"
/>
@endforeach
</div>
</div>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Berechtigungen fuer diesen Guard vorhanden.') }}</flux:text>
@endforelse
</div>
<flux:error name="permissions" class="mt-4" />
</flux:card>
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.roles.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Aenderungen speichern') }}
</flux:button>
</div>
</flux:card>
</form>

View file

@ -0,0 +1,92 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Rollen & Rechte')] class extends Component
{
public function with(): array
{
return [
'roles' => Role::query()
->withCount(['users', 'permissions'])
->orderBy('name')
->get(),
];
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-center justify-between gap-4">
<div>
<flux:heading size="lg">{{ __('Rollen') }}</flux:heading>
<flux:subheading>{{ __('Verwaltung von Rollen und Berechtigungen') }}</flux:subheading>
</div>
@if (\Illuminate\Support\Facades\Route::has('admin.roles.create'))
<flux:button icon="plus" href="{{ route('admin.roles.create') }}" wire:navigate>
{{ __('Neue Rolle') }}
</flux:button>
@endif
</div>
</flux:card>
<flux:card class="overflow-hidden">
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Benutzer') }}</flux:table.column>
<flux:table.column>{{ __('Berechtigungen') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($roles as $role)
<flux:table.row :key="$role->id">
<flux:table.cell>
<div>
<flux:text weight="semibold">{{ str($role->name)->replace('-', ' ')->title() }}</flux:text>
<flux:text class="text-xs text-zinc-500">{{ $role->name }}</flux:text>
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="zinc" size="sm">{{ $role->users_count }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="blue" size="sm">{{ $role->permissions_count }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
@if(in_array($role->name, ['admin', 'editor', 'customer', 'api-only'], true))
<flux:badge color="purple" size="sm">{{ __('System') }}</flux:badge>
@else
<flux:badge color="green" size="sm">{{ __('Custom') }}</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
@if (\Illuminate\Support\Facades\Route::has('admin.roles.edit'))
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.roles.edit', $role->id) }}" wire:navigate />
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="flex flex-col items-center justify-center py-10">
<flux:icon.shield-check class="size-10 text-zinc-400" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Rollen gefunden') }}</flux:text>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</flux:card>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,421 @@
<?php
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Models\Company;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends Component
{
public string $name = '';
public string $email = '';
public string $portal = '';
public string $registrationType = '';
public string $language = 'de';
public bool $isActive = true;
public bool $isSuperAdmin = false;
/** @var array<int, string> */
public array $selectedRoles = [];
/** @var array<int, int> */
public array $linkedCompanyIds = [];
/** @var array<int, string> */
public array $companyRoles = [];
public string $companyLookup = '';
public ?int $selectedLookupCompanyId = null;
/**
* @var array{
* salutation_key:?string,
* title:?string,
* name:?string,
* address1:?string,
* address2:?string,
* postal_code:?string,
* city:?string,
* country_code:?string
* }
*/
public array $billing = [
'salutation_key' => null,
'title' => null,
'name' => null,
'address1' => null,
'address2' => null,
'postal_code' => null,
'city' => null,
'country_code' => null,
];
public function mount(): void
{
$this->portal = Portal::Both->value;
$this->registrationType = RegistrationType::ExistingLegacy->value;
}
public function updatedSelectedLookupCompanyId(): void
{
if ($this->selectedLookupCompanyId) {
$this->addLinkedCompany();
}
}
public function addLinkedCompany(): void
{
if (! $this->selectedLookupCompanyId) {
return;
}
$this->linkedCompanyIds = array_map('intval', $this->linkedCompanyIds);
if (! in_array($this->selectedLookupCompanyId, $this->linkedCompanyIds, true)) {
$this->linkedCompanyIds[] = $this->selectedLookupCompanyId;
}
$this->companyRoles[$this->selectedLookupCompanyId] ??= 'member';
$this->companyLookup = '';
$this->selectedLookupCompanyId = null;
}
public function removeLinkedCompany(int $companyId): void
{
$this->linkedCompanyIds = array_values(array_filter(
$this->linkedCompanyIds,
fn (int|string $id): bool => (int) $id !== $companyId
));
unset($this->companyRoles[$companyId]);
}
public function save(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', Rule::unique('users', 'email')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],
'registrationType' => ['required', Rule::in(array_map(static fn (RegistrationType $type): string => $type->value, RegistrationType::cases()))],
'language' => ['required', 'string', Rule::in(['de', 'en'])],
'selectedRoles' => ['required', 'array', 'min:1'],
'selectedRoles.*' => ['string', Rule::exists('roles', 'name')],
'linkedCompanyIds' => ['array'],
'linkedCompanyIds.*' => ['integer', Rule::exists('companies', 'id')],
'companyRoles' => ['array'],
'companyRoles.*' => ['string', Rule::in(['member', 'responsible', 'owner'])],
'billing.salutation_key' => ['nullable', 'string', 'max:20'],
'billing.title' => ['nullable', 'string', 'max:80'],
'billing.name' => ['nullable', 'string', 'max:255'],
'billing.address1' => ['nullable', 'string', 'max:255'],
'billing.address2' => ['nullable', 'string', 'max:255'],
'billing.postal_code' => ['nullable', 'string', 'max:20'],
'billing.city' => ['nullable', 'string', 'max:120'],
'billing.country_code' => ['nullable', 'string', 'size:2'],
]);
$user = User::query()->create([
'name' => $validated['name'],
'email' => $validated['email'],
'portal' => $validated['portal'],
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin,
'password' => null,
]);
$user->syncRoles($validated['selectedRoles']);
$companySyncPayload = [];
foreach ($validated['linkedCompanyIds'] as $companyId) {
$companySyncPayload[$companyId] = [
'role' => $validated['companyRoles'][$companyId] ?? 'member',
];
}
$user->companies()->sync($companySyncPayload);
if ($this->billingIsComplete()) {
$user->billingAddress()->create([
'salutation_key' => $validated['billing']['salutation_key'] ?: null,
'title' => $validated['billing']['title'] ?: null,
'name' => (string) $validated['billing']['name'],
'address1' => (string) $validated['billing']['address1'],
'address2' => $validated['billing']['address2'] ?: null,
'postal_code' => (string) $validated['billing']['postal_code'],
'city' => (string) $validated['billing']['city'],
'country_code' => strtoupper((string) $validated['billing']['country_code']),
]);
}
session()->flash('success', __('Benutzer wurde angelegt und verknuepft.'));
$this->redirect(route('admin.users.edit', $user->id), navigate: true);
}
public function with(): array
{
$companyLookupResults = collect();
$companyLookupTerm = trim($this->companyLookup);
if (mb_strlen($companyLookupTerm) >= 1) {
$companyLookupResults = Company::withoutGlobalScopes()
->whereNotIn('id', $this->linkedCompanyIds ?: [-1])
->where(function ($query) use ($companyLookupTerm): void {
if ($this->supportsFullTextSearch($companyLookupTerm)) {
$query->whereFullText(['name', 'email', 'slug'], $companyLookupTerm);
return;
}
$query
->where('name', 'like', '%'.$companyLookupTerm.'%')
->orWhere('slug', 'like', '%'.$companyLookupTerm.'%')
->orWhere('email', 'like', '%'.$companyLookupTerm.'%');
})
->orderBy('name')
->limit(50)
->get(['id', 'name', 'slug', 'email']);
}
return [
'availableRoles' => $this->availableRoles(),
'linkedCompanies' => Company::withoutGlobalScopes()
->whereIn('id', $this->linkedCompanyIds ?: [-1])
->orderBy('name')
->get(['id', 'name', 'slug', 'email']),
'companyLookupResults' => $companyLookupResults,
'salutations' => config('salutations.items', []),
'countries' => config('countries.items', []),
'portalOptions' => Portal::cases(),
'registrationTypeOptions' => RegistrationType::cases(),
];
}
private function availableRoles(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::RoleOptions, AdminPerformanceCache::OptionsTtl, fn () => Role::query()
->orderBy('name')
->get(['id', 'name']));
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
protected function billingIsComplete(): bool
{
return filled($this->billing['name'])
&& filled($this->billing['address1'])
&& filled($this->billing['postal_code'])
&& filled($this->billing['city'])
&& filled($this->billing['country_code']);
}
}; ?>
<form wire:submit="save" class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Benutzer anlegen') }}</flux:heading>
<flux:subheading>{{ __('Rollen, Firmen und optional Rechnungsadresse direkt mitsetzen.') }}</flux:subheading>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Basisdaten') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Name') }}</flux:label>
<flux:input wire:model="name" />
<flux:error name="name" />
</flux:field>
<flux:field>
<flux:label>{{ __('E-Mail') }}</flux:label>
<flux:input wire:model="email" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $portalOption)
<option value="{{ $portalOption->value }}">{{ $portalOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:label>{{ __('Registrierungstyp') }}</flux:label>
<flux:select wire:model="registrationType">
@foreach($registrationTypeOptions as $registrationTypeOption)
<option value="{{ $registrationTypeOption->value }}">{{ $registrationTypeOption->label() }}</option>
@endforeach
</flux:select>
<flux:error name="registrationType" />
</flux:field>
</div>
<div class="mt-4 flex flex-wrap gap-6">
<flux:checkbox wire:model="isActive" label="{{ __('Aktiv') }}" />
<flux:checkbox wire:model="isSuperAdmin" label="{{ __('Super Admin') }}" />
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rollenzuweisung') }}</flux:heading>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach($availableRoles as $role)
<flux:checkbox wire:model="selectedRoles" value="{{ $role->name }}" label="{{ $role->name }}" />
@endforeach
</div>
<flux:error name="selectedRoles" class="mt-4" />
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Firmenverknüpfung') }}</flux:heading>
<div class="mb-4">
<flux:select
wire:model.live="selectedLookupCompanyId"
variant="combobox"
:filter="false"
placeholder="{{ __('Firma suchen und hinzufügen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companyLookup"
placeholder="{{ __('Name, Slug oder E-Mail…') }}"
/>
</x-slot>
@foreach($companyLookupResults as $company)
<flux:select.option :value="$company->id" wire:key="create-company-{{ $company->id }}">
{{ $company->name }}@if($company->email)<span class="ml-1 text-zinc-400">· {{ $company->email }}</span>@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companyLookup)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
</div>
<div class="space-y-3">
@forelse($linkedCompanies as $company)
<div class="grid gap-3 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700 sm:grid-cols-[1fr,160px,auto] sm:items-center">
<div>
<flux:text weight="semibold">{{ $company->name }}</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $company->slug }}</flux:text>
</div>
<flux:select wire:model="companyRoles.{{ $company->id }}">
<option value="member">{{ __('Member') }}</option>
<option value="responsible">{{ __('Responsible') }}</option>
<option value="owner">{{ __('Owner') }}</option>
</flux:select>
<flux:button
size="sm"
variant="ghost"
icon="x-mark"
wire:click="removeLinkedCompany({{ $company->id }})"
>
{{ __('Entfernen') }}
</flux:button>
</div>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Noch keine Firma verknüpft.') }}</flux:text>
@endforelse
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse (optional)') }}</flux:heading>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Anrede') }}</flux:label>
<flux:select wire:model="billing.salutation_key">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($salutations as $key => $labels)
<option value="{{ $key }}">{{ $labels['de'] ?? $key }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="billing.title" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Rechnungsname') }}</flux:label>
<flux:input wire:model="billing.name" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Adresse Zeile 1') }}</flux:label>
<flux:input wire:model="billing.address1" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:label>{{ __('Adresse Zeile 2') }}</flux:label>
<flux:input wire:model="billing.address2" />
</flux:field>
<flux:field>
<flux:label>{{ __('PLZ') }}</flux:label>
<flux:input wire:model="billing.postal_code" />
</flux:field>
<flux:field>
<flux:label>{{ __('Stadt') }}</flux:label>
<flux:input wire:model="billing.city" />
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }}</flux:label>
<flux:select wire:model="billing.country_code">
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($countries as $countryCode => $countryName)
<option value="{{ $countryCode }}">{{ $countryName }}</option>
@endforeach
</flux:select>
</flux:field>
</div>
</flux:card>
<flux:card>
<div class="flex justify-end gap-3">
<flux:button variant="ghost" href="{{ route('admin.users.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Benutzer anlegen') }}
</flux:button>
</div>
</flux:card>
</form>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,239 @@
<?php
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extends Component
{
#[Locked]
public int $id;
public function mount(int $id): void
{
$this->id = $id;
if (! User::query()->whereKey($id)->exists()) {
session()->flash('error', __('Der angeforderte Benutzer wurde nicht gefunden.'));
$this->redirect(route('admin.users.index'), navigate: true);
return;
}
}
public function companyUserRoleLabel(?string $role): string
{
return match ($role ?? 'member') {
'owner' => __('Inhaber'),
'responsible' => __('Verantwortlich'),
'member' => __('Mitglied'),
default => (string) ($role ?? 'member'),
};
}
public function with(): array
{
$user = User::query()
->with([
'roles' => fn ($query) => $query->orderBy('name'),
'companies' => fn ($query) => $query
->select(['companies.id', 'companies.name', 'companies.slug', 'companies.email', 'companies.phone', 'companies.portal', 'companies.is_active'])
->withCount('contacts')
->orderBy('name'),
'companies.contacts' => fn ($query) => $query
->select(['contacts.id', 'contacts.company_id', 'contacts.portal', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone'])
->orderBy('last_name')
->orderBy('first_name')
->limit(10),
'billingAddress',
])
->find($this->id);
return [
'user' => $user,
];
}
}; ?>
<div class="space-y-6">
@if (!$user)
<flux:card>
<flux:heading size="lg">{{ __('Benutzer nicht gefunden') }}</flux:heading>
<flux:button class="mt-4" href="{{ route('admin.users.index') }}" wire:navigate>
{{ __('Zurück zur Übersicht') }}
</flux:button>
</flux:card>
@else
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:heading size="xl">{{ $user->name }}</flux:heading>
<flux:text class="text-sm text-zinc-500">{{ $user->email }}</flux:text>
<flux:text class="text-xs text-zinc-500">ID: {{ $user->id }}</flux:text>
</div>
<div class="flex gap-2">
<flux:button variant="ghost" href="{{ route('admin.users.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
<flux:button icon="pencil" href="{{ route('admin.users.edit', $user->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
</div>
</div>
</flux:card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Portal') }}</flux:text>
<flux:text weight="semibold">{{ $user->portal?->label() ?? '-' }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Typ') }}</flux:text>
<flux:text weight="semibold">{{ $user->registration_type?->label() ?? '-' }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Status') }}</flux:text>
@if ($user->is_active)
<flux:badge color="green" size="sm">{{ __('Aktiv') }}</flux:badge>
@else
<flux:badge color="red" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endif
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Letzter Login') }}</flux:text>
<flux:text weight="semibold">{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Nie') }}</flux:text>
@if ($user->last_login_ip)
<flux:text class="text-xs text-zinc-500">{{ $user->last_login_ip }}</flux:text>
@endif
</flux:card>
</div>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rollen') }}</flux:heading>
<div class="flex flex-wrap gap-2">
@forelse($user->roles as $role)
<flux:badge color="zinc" size="sm">{{ $role->name }}</flux:badge>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Rollen hinterlegt') }}</flux:text>
@endforelse
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse') }}</flux:heading>
@if ($user->billingAddress)
<div class="space-y-1">
<flux:text>{{ $user->billingAddress->name }}</flux:text>
<flux:text>{{ $user->billingAddress->address1 }}</flux:text>
@if ($user->billingAddress->address2)
<flux:text>{{ $user->billingAddress->address2 }}</flux:text>
@endif
<flux:text>{{ $user->billingAddress->postal_code }} {{ $user->billingAddress->city }}</flux:text>
<flux:text>{{ $user->billingAddress->country_code }}</flux:text>
</div>
@else
<flux:text class="text-sm text-zinc-500">{{ __('Keine Rechnungsadresse hinterlegt') }}</flux:text>
@endif
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Verknüpfte Firmen und Kontakte') }}</flux:heading>
<flux:subheading class="mb-4">
{{ __('Kontakte sind die Ansprechpartner der verknüpften Firmen (wie in der Bearbeiten-Ansicht).') }}
</flux:subheading>
<div class="space-y-4">
@forelse($user->companies as $company)
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
@if (\Illuminate\Support\Facades\Route::has('admin.companies.show'))
<a href="{{ route('admin.companies.show', $company->id) }}" wire:navigate
class="block">
<flux:text weight="semibold"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ $company->name }}
</flux:text>
</a>
@else
<flux:text weight="semibold">{{ $company->name }}</flux:text>
@endif
<flux:text class="text-xs text-zinc-500">{{ $company->slug }}</flux:text>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500">
@if ($company->email)
<span>{{ $company->email }}</span>
@endif
@if ($company->phone)
<span>{{ $company->phone }}</span>
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<flux:badge color="zinc" size="sm">
{{ $this->companyUserRoleLabel($company->pivot?->role ?? 'member') }}
</flux:badge>
<flux:badge color="zinc" size="sm">
{{ $company->portal?->label() ?? '—' }}
</flux:badge>
@if (!$company->is_active)
<flux:badge color="red" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endif
</div>
</div>
@if ($company->contacts->isNotEmpty())
<div class="space-y-2">
@foreach ($company->contacts as $contact)
<div
class="flex flex-col gap-2 rounded-md bg-zinc-50 p-2 dark:bg-zinc-900 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<flux:text class="text-sm" weight="medium">
{{ trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</flux:text>
<flux:text class="text-xs text-zinc-500">
{{ $contact->responsibility ?? __('Keine Rolle hinterlegt') }}
</flux:text>
<div class="mt-0.5 flex flex-wrap items-center gap-2 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>
<div class="flex shrink-0 items-center gap-2">
<flux:badge color="zinc" size="sm">
{{ $contact->portal?->label() ?? '—' }}</flux:badge>
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
<flux:button size="sm" variant="ghost" icon="pencil"
href="{{ route('admin.contacts.edit', $contact->id) }}"
wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
</div>
</div>
@endforeach
@if ($company->contacts_count > $company->contacts->count())
<flux:text class="text-xs text-zinc-500">
{{ __(':count weitere Kontakte werden hier nicht geladen. Öffne die Firma, um alle Kontakte zu sehen.', ['count' => $company->contacts_count - $company->contacts->count()]) }}
</flux:text>
@endif
</div>
@else
<flux:text class="text-xs text-zinc-500">{{ __('Keine Kontakte bei dieser Firma') }}
</flux:text>
@endif
</div>
@empty
<flux:text class="text-sm text-zinc-500">{{ __('Keine Firmen verknüpft') }}</flux:text>
@endforelse
</div>
</flux:card>
@endif
</div>

View file

@ -1,11 +1,13 @@
<?php
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination; // Wichtig für Paginierung
new class extends Component {
new #[Layout('components.layouts.app'), Title('Benutzertabelle')] class extends Component {
use WithPagination;
// Optional: Such- und Filter-Properties

View file

@ -1,7 +1,11 @@
<?php
use Illuminate\Auth\Events\Lockout;
use App\Mail\MagicLoginLink;
use App\Services\Auth\MagicLinkGenerator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Session;
@ -37,12 +41,42 @@ new #[Layout('components.layouts.auth')] class extends Component {
]);
}
$authenticatedUser = Auth::user();
if ($authenticatedUser) {
$authenticatedUser->update([
'last_login_at' => now(),
'last_login_ip' => request()->ip(),
]);
}
RateLimiter::clear($this->throttleKey());
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
public function sendMagicLink(): void
{
$this->validateOnly('email');
$user = User::query()->where('email', $this->email)->first();
if ($user && $user->is_active) {
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
$loginUrl = route('magic-links.consume', ['token' => $generated['plain_token']]);
Mail::to($user->email)->send(
new MagicLoginLink(
user: $user,
loginUrl: $loginUrl,
expiresAt: $generated['expires_at']->format('d.m.Y H:i')
)
);
}
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
}
/**
* Ensure the authentication request is not rate limited.
*/
@ -117,6 +151,15 @@ new #[Layout('components.layouts.auth')] class extends Component {
</div>
</form>
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<p class="mb-3 text-sm text-zinc-600 dark:text-zinc-300">
{{ __('Login without password? Request a one-time email link.') }}
</p>
<flux:button variant="subtle" wire:click="sendMagicLink" class="w-full">
{{ __('Send magic login link') }}
</flux:button>
</div>
@if (Route::has('register'))
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
{{ __('Don\'t have an account?') }}

View file

@ -0,0 +1,281 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Reusable image manager for a single press release. Used by both the
* admin and customer PR edit pages. Authorisation is delegated to the
* `update` ability on `PressReleasePolicy`, so the same component is safe
* to use for admins (who can always edit) and customers (only their own).
*/
new class extends Component
{
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newImage = null;
public string $newTitle = '';
public string $newCopyright = '';
public bool $newIsPreview = false;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
$this->addError('newImage', __('Bilder können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$this->validate([
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:'.(int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
'newTitle' => ['nullable', 'string', 'max:120'],
'newCopyright' => ['nullable', 'string', 'max:255'],
]);
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
if ($this->newIsPreview) {
$pressRelease->images()->update(['is_preview' => false]);
}
$pressRelease->images()->create([
'disk' => 'public',
'path' => $stored['path'],
'variants' => $stored['variants'],
'title' => $this->newTitle ?: null,
'copyright' => $this->newCopyright ?: null,
'is_preview' => $this->newIsPreview,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
session()->flash('image-status', __('Bild hochgeladen.'));
}
public function setPreview(int $imageId): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
return;
}
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
$image->update(['is_preview' => true]);
session()->flash('image-status', __('Vorschaubild gesetzt.'));
}
public function moveUp(int $imageId): void
{
$this->swapSortOrder($imageId, -1);
}
public function moveDown(int $imageId): void
{
$this->swapSortOrder($imageId, 1);
}
public function remove(int $imageId, ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
return;
}
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
return;
}
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
session()->flash('image-status', __('Bild entfernt.'));
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'images' => $pressRelease->images()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeImages($pressRelease),
];
}
private function swapSortOrder(int $imageId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
return;
}
$images = $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $images->search(fn (PressReleaseImage $image) => $image->id === $imageId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $images->count()) {
return;
}
$current = $images[$currentIndex];
$target = $images[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->findOrFail($this->pressReleaseId);
}
private function canChangeImages(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array(
$pressRelease->status,
[PressReleaseStatus::Archived],
true,
);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Bilder') }}</flux:heading>
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
</div>
@if(session('image-status'))
<flux:callout color="green" icon="check-circle" class="mt-3">{{ session('image-status') }}</flux:callout>
@endif
@if($canEdit)
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>
<flux:input
type="file"
wire:model="newImage"
accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. 8 MB. Varianten thumb/medium/large werden automatisch erzeugt.')"
/>
<flux:error name="newImage" />
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel (optional)')" />
<flux:input wire:model="newCopyright" :label="__('Copyright / Quelle (optional)')" />
</div>
<flux:checkbox wire:model="newIsPreview" :label="__('Als Vorschaubild verwenden')" />
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}</flux:button>
</div>
</form>
@endif
@if($images->isEmpty())
<div class="mt-4 rounded-md border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<flux:icon.photo class="mx-auto size-10 text-zinc-400" />
<flux:text class="mt-2 text-sm text-zinc-500">{{ __('Noch keine Bilder hinterlegt.') }}</flux:text>
</div>
@else
<div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
@foreach($images as $image)
<div class="group rounded-md border border-zinc-200 p-2 dark:border-zinc-700" wire:key="pri-{{ $image->id }}">
<div class="relative aspect-square overflow-hidden rounded bg-zinc-50 dark:bg-zinc-800">
@if($image->variantUrl('thumb') ?? $image->url())
<img src="{{ $image->variantUrl('thumb') ?? $image->url() }}" alt="{{ $image->title ?? '' }}" class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
@if($image->is_preview)
<flux:badge color="green" size="sm" icon="star" class="absolute left-2 top-2">
{{ __('Vorschau') }}
</flux:badge>
@endif
</div>
<div class="mt-2 space-y-1">
@if($image->title)
<p class="truncate text-sm font-medium" title="{{ $image->title }}">{{ $image->title }}</p>
@endif
@if($image->copyright)
<p class="truncate text-xs text-zinc-500">{{ $image->copyright }}</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if($image->width && $image->height)
<span>{{ $image->width }}×{{ $image->height }}</span>
@endif
@if(is_array($image->variants))
<flux:badge color="zinc" size="xs">{{ count($image->variants) }}× variant</flux:badge>
@endif
</div>
@if($canEdit)
<div class="flex flex-wrap gap-1 pt-1">
@if(! $image->is_preview)
<flux:button size="xs" variant="ghost" icon="star" wire:click="setPreview({{ $image->id }})" :title="__('Als Vorschau setzen')" />
@endif
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $image->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $image->id }})" :title="__('Runter')" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $image->id }})"
wire:confirm="{{ __('Bild wirklich entfernen?') }}" :title="__('Entfernen')" />
</div>
@endif
</div>
</div>
@endforeach
</div>
@endif
</flux:card>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,451 @@
<?php
use App\Models\Company;
use App\Models\Profile;
use App\Models\User;
use App\Services\Image\ImageService;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
{
use WithFileUploads;
public string $name = '';
public string $language = 'de';
public string $salutationKey = 'none';
public string $firstName = '';
public string $lastName = '';
public string $title = '';
public string $phone = '';
public string $address = '';
public string $countryCode = 'DE';
public string $backlinkUrl = '';
public bool $showStats = false;
public bool $disableFooterCode = false;
public string $taxIdNumber = '';
public string $billingName = '';
public string $billingAddress1 = '';
public string $billingAddress2 = '';
public string $billingPostalCode = '';
public string $billingCity = '';
public string $billingCountryCode = 'DE';
public ?int $editableCompanyId = null;
public string $companyName = '';
public string $companyAddress = '';
public string $companyEmail = '';
public string $companyPhone = '';
public string $companyWebsite = '';
public string $companyCountryCode = 'DE';
public bool $companyDisableFooterCode = false;
public $companyLogo = null;
public bool $removeCompanyLogo = false;
public function mount(): void
{
$user = auth()->user();
$profile = $user->profile;
$this->name = (string) $user->name;
$this->language = $user->language ?? 'de';
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
$this->firstName = (string) ($profile?->first_name ?? '');
$this->lastName = (string) ($profile?->last_name ?? '');
$this->title = (string) ($profile?->title ?? '');
$this->phone = (string) ($profile?->phone ?? '');
$this->address = (string) ($profile?->address ?? '');
$this->countryCode = (string) ($profile?->country_code ?? 'DE');
$this->backlinkUrl = (string) ($profile?->backlink_url ?? '');
$this->showStats = (bool) ($profile?->show_stats ?? false);
$this->disableFooterCode = (bool) ($profile?->disable_footer_code ?? false);
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
$billingAddress = $user->billingAddress;
$this->billingName = (string) ($billingAddress?->name ?? $user->name);
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
$this->billingCity = (string) ($billingAddress?->city ?? '');
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
$this->loadEditableCompany();
}
public function selectCompany(int $companyId): void
{
$this->editableCompanyId = $companyId;
$this->loadEditableCompany();
}
public function saveProfile(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:120'],
'language' => ['required', Rule::in(['de', 'en'])],
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
'firstName' => ['nullable', 'string', 'max:80'],
'lastName' => ['nullable', 'string', 'max:80'],
'title' => ['nullable', 'string', 'max:80'],
'phone' => ['nullable', 'string', 'max:40'],
'address' => ['nullable', 'string', 'max:1000'],
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'taxIdNumber' => ['nullable', 'string', 'max:255'],
'billingName' => ['nullable', 'string', 'max:255'],
'billingAddress1' => ['nullable', 'string', 'max:255'],
'billingAddress2' => ['nullable', 'string', 'max:255'],
'billingPostalCode' => ['nullable', 'string', 'max:20'],
'billingCity' => ['nullable', 'string', 'max:120'],
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
]);
if ($this->billingHasInput() && ! $this->billingIsComplete()) {
throw ValidationException::withMessages([
'billingName' => __('Bitte füllen Sie für eine Rechnungsadresse Name, Adresse, PLZ, Ort und Land aus.'),
]);
}
/** @var User $user */
$user = auth()->user();
$user->forceFill([
'name' => $validated['name'],
'language' => $validated['language'],
])->save();
$user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'],
'first_name' => $validated['firstName'] ?: null,
'last_name' => $validated['lastName'] ?: null,
'title' => $validated['title'] ?: null,
'phone' => $validated['phone'] ?: null,
'address' => $validated['address'] ?: null,
'country_code' => $validated['countryCode'] ?: null,
'backlink_url' => $validated['backlinkUrl'] ?: null,
'show_stats' => $this->showStats,
'disable_footer_code' => $this->disableFooterCode,
'tax_id_number' => $validated['taxIdNumber'] ?: null,
]
);
if (! $this->billingHasInput()) {
$user->billingAddress()->delete();
} else {
$user->billingAddress()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'] !== 'none' ? $validated['salutationKey'] : null,
'title' => $validated['title'] ?: null,
'name' => $validated['billingName'],
'address1' => $validated['billingAddress1'],
'address2' => $validated['billingAddress2'] ?: null,
'postal_code' => $validated['billingPostalCode'],
'city' => $validated['billingCity'],
'country_code' => $validated['billingCountryCode'],
],
);
}
session()->flash('profile-status', __('Profil gespeichert.'));
}
public function saveCompany(ImageService $imageService): void
{
if (! $this->editableCompanyId) {
return;
}
$company = $this->resolveEditableCompany($this->editableCompanyId);
if (! $company) {
throw ValidationException::withMessages([
'companyName' => __('Diese Firma kann von Ihnen nicht bearbeitet werden.'),
]);
}
$this->authorize('update', $company);
$validated = $this->validate([
'companyName' => ['required', 'string', 'max:255'],
'companyAddress' => ['nullable', 'string', 'max:1000'],
'companyEmail' => ['nullable', 'email', 'max:190'],
'companyPhone' => ['nullable', 'string', 'max:40'],
'companyWebsite' => ['nullable', 'url', 'max:190'],
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
'companyLogo' => ['nullable', 'image', 'max:'.(int) (ImageService::MAX_LOGO_BYTES / 1024)],
]);
$company->fill([
'name' => $validated['companyName'],
'address' => $validated['companyAddress'] ?: null,
'email' => $validated['companyEmail'] ?: null,
'phone' => $validated['companyPhone'] ?: null,
'website' => $validated['companyWebsite'] ?: null,
'country_code' => $validated['companyCountryCode'] ?: null,
'disable_footer_code' => $this->companyDisableFooterCode,
]);
if ($this->removeCompanyLogo) {
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
$company->logo_path = null;
$company->logo_variants = null;
}
if ($this->companyLogo) {
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
$stored = $imageService->storeCompanyLogo(
$this->companyLogo,
$company->portal?->value ?? 'presseecho',
$company->id,
);
$company->logo_path = $stored['path'];
$company->logo_variants = $stored['variants'];
}
$company->save();
$this->companyLogo = null;
$this->removeCompanyLogo = false;
session()->flash('company-status', __('Firmendaten gespeichert.'));
}
public function with(): array
{
$user = auth()->user();
$companies = $user->companies()
->withPivot('role')
->orderBy('name')
->get(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id']);
return [
'user' => $user,
'companies' => $companies,
'salutations' => collect((array) config('salutations.items', []))
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
->all(),
'countries' => (array) config('countries.items', []),
'editableCompany' => $this->editableCompanyId
? $this->resolveEditableCompany($this->editableCompanyId)
: null,
];
}
private function loadEditableCompany(): void
{
/** @var User $user */
$user = auth()->user();
$editable = Company::query()
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
->whereIn('company_user.role', ['owner', 'responsible']));
})
->orderBy('name');
$company = $this->editableCompanyId
? $editable->whereKey($this->editableCompanyId)->first()
: $editable->first();
if (! $company) {
$this->editableCompanyId = null;
return;
}
$this->editableCompanyId = $company->id;
$this->companyName = (string) $company->name;
$this->companyAddress = (string) ($company->address ?? '');
$this->companyEmail = (string) ($company->email ?? '');
$this->companyPhone = (string) ($company->phone ?? '');
$this->companyWebsite = (string) ($company->website ?? '');
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
}
private function resolveEditableCompany(int $companyId): ?Company
{
/** @var User $user */
$user = auth()->user();
return Company::query()
->where('id', $companyId)
->where(function ($query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
->whereIn('company_user.role', ['owner', 'responsible']));
})
->first();
}
public function billingHasInput(): bool
{
return filled($this->billingName)
|| filled($this->billingAddress1)
|| filled($this->billingAddress2)
|| filled($this->billingPostalCode)
|| filled($this->billingCity);
}
public function billingIsComplete(): bool
{
return filled($this->billingName)
&& filled($this->billingAddress1)
&& filled($this->billingPostalCode)
&& filled($this->billingCity)
&& filled($this->billingCountryCode);
}
}; ?>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Mein Profil') }}</flux:heading>
<flux:subheading>
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
</flux:subheading>
</flux:card>
@if(session('profile-status'))
<flux:callout color="green" icon="check-circle">{{ session('profile-status') }}</flux:callout>
@endif
<form wire:submit="saveProfile" class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Konto') }}</flux:heading>
<div class="space-y-4">
<flux:input wire:model="name" :label="__('Name')" required />
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
<flux:select wire:model="language" :label="__('Sprache')">
<option value="de">Deutsch</option>
<option value="en">English</option>
</flux:select>
</div>
</flux:card>
<flux:card id="profil">
<div class="mb-4 flex flex-wrap gap-2">
<flux:badge color="indigo" size="sm">{{ __('Profil') }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ __('Rechnungsadresse') }}</flux:badge>
</div>
<flux:heading size="sm" class="mb-4">{{ __('Profil') }}</flux:heading>
<div class="grid gap-4 sm:grid-cols-2">
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:checkbox wire:model="showStats" :label="__('Statistiken in Pressemitteilungen anzeigen')" class="sm:col-span-2" />
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code in Pressemitteilungen deaktivieren')" class="sm:col-span-2" />
</div>
<flux:separator class="my-6" />
<flux:heading id="rechnungsadresse" size="sm" class="mb-2">{{ __('Rechnungsadresse') }}</flux:heading>
<flux:text class="mb-4 text-sm text-zinc-500">
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
</flux:text>
@if(! $this->billingIsComplete())
<flux:callout color="amber" icon="exclamation-triangle" class="mb-4">
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
</flux:callout>
@endif
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress1" :label="__('Adresse Zeile 1')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress2" :label="__('Adresse Zeile 2')" class="sm:col-span-2" />
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
<flux:input wire:model="billingCity" :label="__('Ort')" />
<flux:select wire:model="billingCountryCode" :label="__('Land')">
@foreach($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="taxIdNumber" :label="__('USt-ID')" />
<flux:error name="billingName" class="sm:col-span-2" />
</div>
</flux:card>
<div class="lg:col-span-2 flex justify-end">
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
</div>
</form>
<flux:card>
<flux:heading size="sm" class="mb-4">{{ __('Zugeordnete Firmen') }}</flux:heading>
@forelse($companies as $company)
<div class="flex flex-col gap-2 border-b border-zinc-100 py-3 last:border-0 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="font-medium text-sm">{{ $company->name }}</p>
<div class="flex flex-wrap items-center gap-2">
<flux:badge color="zinc" size="sm">{{ $company->portal?->label() ?? '' }}</flux:badge>
<flux:badge color="indigo" size="sm">{{ $company->pivot->role ?? 'member' }}</flux:badge>
@if($company->owner_user_id === $user->id)
<flux:badge color="green" size="sm">{{ __('Eigentümer') }}</flux:badge>
@endif
</div>
</div>
@if($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true))
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma verwalten') }}
</flux:button>
@else
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma öffnen') }}
</flux:button>
@endif
</div>
@empty
<flux:text class="text-sm text-zinc-500">
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
</flux:text>
@endforelse
</flux:card>
</div>

View file

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

View file

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

View file

@ -1,8 +1,10 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new class extends Component {
new #[Layout('components.layouts.app')] class extends Component
{
//
}; ?>

View file

@ -4,7 +4,8 @@ use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Volt\Component;
new class extends Component {
new class extends Component
{
public string $password = '';
/**
@ -16,7 +17,7 @@ new class extends Component {
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
tap(Auth::user(), $logout(...))->forceDelete();
$this->redirect('/', navigate: true);
}

View file

@ -4,11 +4,15 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new class extends Component {
new #[Layout('components.layouts.app')] class extends Component
{
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
/**

View file

@ -4,10 +4,13 @@ use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new class extends Component {
new #[Layout('components.layouts.app')] class extends Component
{
public string $name = '';
public string $email = '';
/**
@ -35,7 +38,7 @@ new class extends Component {
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($user->id)
Rule::unique(User::class)->ignore($user->id),
],
]);

View file

@ -53,21 +53,21 @@ new class extends Component {
x-transition:leave="transition ease-in duration-200 transform"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="fixed left-0 top-0 h-full w-80 sm:w-96 bg-white dark:bg-gray-900 shadow-2xl z-[70] overflow-y-auto transition-colors duration-200"
class="fixed left-0 top-0 h-full w-80 sm:w-96 bg-white dark:bg-zinc-900 shadow-2xl z-[70] overflow-y-auto transition-colors duration-200"
x-cloak
>
<!-- Header -->
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between transition-colors duration-200">
<div class="sticky top-0 bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 p-4 flex items-center justify-between transition-colors duration-200">
<div class="flex items-center gap-2">
<span class="w-1 h-6 bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-secondary)] rounded-full"></span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Navigation</h2>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Navigation</h2>
</div>
<button
@click="$wire.toggleMenu()"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
class="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
aria-label="Menü schließen"
>
<svg class="h-6 w-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-6 w-6 text-zinc-600 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
@ -77,15 +77,15 @@ new class extends Component {
<nav class="p-6 space-y-6">
<!-- Portal Section -->
<div>
<div class="py-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Portal</div>
<div class="py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">Portal</div>
<div class="space-y-1">
<a href="/" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Startseite
</a>
<a href="/kategorien" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/kategorien" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Kategorien
</a>
<a href="/suche" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/suche" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Suche
</a>
</div>
@ -95,7 +95,7 @@ new class extends Component {
<div>
<button
wire:click="toggleSection('services')"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -114,16 +114,16 @@ new class extends Component {
</button>
@if($this->isSectionOpen('services'))
<div class="mt-2 space-y-1 pl-6">
<a href="/veroeffentlichen" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/veroeffentlichen" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Pressemitteilung veröffentlichen
</a>
<a href="/newsrooms" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/newsrooms" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Newsrooms
</a>
<a href="/preise" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/preise" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Preise & Leistungen
</a>
<a href="/api" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/api" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
API & Integrationen
</a>
</div>
@ -134,7 +134,7 @@ new class extends Component {
<div>
<button
wire:click="toggleSection('about')"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -153,19 +153,19 @@ new class extends Component {
</button>
@if($this->isSectionOpen('about'))
<div class="mt-2 space-y-1 pl-6">
<a href="/ueber-uns" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/ueber-uns" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Über Business Portal
</a>
<a href="/team" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/team" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Team
</a>
<a href="/partner" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/partner" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Partner
</a>
<a href="/karriere" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/karriere" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Karriere
</a>
<a href="/presse" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/presse" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Presse
</a>
</div>
@ -176,7 +176,7 @@ new class extends Component {
<div>
<button
wire:click="toggleSection('support')"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -195,13 +195,13 @@ new class extends Component {
</button>
@if($this->isSectionOpen('support'))
<div class="mt-2 space-y-1 pl-6">
<a href="/faq" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/faq" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
FAQ
</a>
<a href="/hilfe" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/hilfe" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Hilfe-Center
</a>
<a href="/kontakt" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/kontakt" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Kontakt
</a>
</div>
@ -212,7 +212,7 @@ new class extends Component {
<div>
<button
wire:click="toggleSection('legal')"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -231,16 +231,16 @@ new class extends Component {
</button>
@if($this->isSectionOpen('legal'))
<div class="mt-2 space-y-1 pl-6">
<a href="/impressum" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/impressum" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Impressum
</a>
<a href="/datenschutz" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/datenschutz" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Datenschutz
</a>
<a href="/agb" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/agb" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
AGB
</a>
<a href="/cookies" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
<a href="/cookies" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
Cookie-Richtlinien
</a>
</div>
@ -249,7 +249,7 @@ new class extends Component {
</nav>
<!-- Footer Actions -->
<div class="sticky bottom-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 p-6 space-y-3 transition-colors duration-200">
<div class="sticky bottom-0 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 p-6 space-y-3 transition-colors duration-200">
<a
href="/veroeffentlichen"
class="block w-full text-center px-4 py-3 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all"
@ -259,13 +259,13 @@ new class extends Component {
<div class="flex gap-2">
<a
href="/login"
class="flex-1 text-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-all"
class="flex-1 text-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-all"
>
Anmelden
</a>
<a
href="/register"
class="flex-1 text-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-all"
class="flex-1 text-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-all"
>
Registrieren
</a>
@ -274,14 +274,14 @@ new class extends Component {
<!-- Contact Info -->
<div class="px-6 pb-6">
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg transition-colors duration-200">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg transition-colors duration-200">
<div class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 mb-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span>Kontakt</span>
</div>
<a href="mailto:info@businessportal.de" class="text-sm text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors">
<a href="mailto:info@businessportal.de" class="text-sm text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors">
info@businessportal.de
</a>
</div>

View file

@ -60,140 +60,167 @@ new class extends Component {
private function getTimeframeLabel()
{
return match($this->timeframe) {
return match ($this->timeframe) {
'today' => 'Heute',
'7' => '7 Tage',
'30' => '30 Tage',
default => 'Zeitraum'
default => 'Zeitraum',
};
}
private function getIndustryLabel()
{
return match($this->industry) {
'it' => 'IT & Software',
'finance' => 'Finanzen',
'health' => 'Gesundheit',
'auto' => 'Automobil',
'energy' => 'Energie',
default => 'Alle Branchen'
return match ($this->industry) {
'it-digital' => 'IT & Digitalisierung',
'industry-tech' => 'Industrie & Technik',
'finance-insurance' => 'Finanzen & Versicherungen',
'retail-ecommerce' => 'Handel & E-Commerce',
'construction-realestate' => 'Bauen & Immobilien',
'mobility-logistics' => 'Mobilität & Logistik',
'energy-environment' => 'Energie & Umwelt',
'medicine-health' => 'Medizin & Gesundheit',
'hr-personnel' => 'Personal & HR',
'marketing-pr-media' => 'Marketing, PR & Medien',
'law-tax' => 'Recht & Steuern',
'politics-ngo' => 'Politik, Verbände & NGOs',
'science-research' => 'Wissenschaft & Forschung',
'lifestyle' => 'Lifestyle',
'tourism-culture' => 'Tourismus & Kultur',
default => 'Alle Branchen',
};
}
private function getRegionLabel()
{
return match($this->region) {
return match ($this->region) {
'de' => 'Deutschland',
'at' => 'Österreich',
'ch' => 'Schweiz',
default => 'Alle Regionen'
default => 'Alle Regionen',
};
}
}; ?>
<div>
<div class="sticky top-16 z-40 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-b border-gray-200 dark:border-gray-800 shadow-sm transition-colors duration-200">
<div
class="sticky top-16 z-40 bg-white/95 dark:bg-zinc-800 backdrop-blur-sm border-b border-zinc-200 dark:border-zinc-800 shadow-sm transition-colors duration-200">
<div class="container mx-auto px-4 py-4">
<div class="flex flex-wrap items-center gap-3">
<!-- Zeitraum -->
<div class="relative">
<select
wire:model.live="timeframe"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
>
<select wire:model.live="timeframe"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
<option value="today">Heute</option>
<option value="7">7 Tage</option>
<option value="30">30 Tage</option>
<option value="custom">Zeitraum</option>
</select>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
</path>
</svg>
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- Branche -->
<div class="relative">
<select
wire:model.live="industry"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
>
<select wire:model.live="industry"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
<option value="all">Alle Branchen</option>
<option value="it">IT & Software</option>
<option value="finance">Finanzen</option>
<option value="health">Gesundheit</option>
<option value="auto">Automobil</option>
<option value="energy">Energie</option>
<option value="it-digital">IT & Digitalisierung</option>
<option value="industry-tech">Industrie & Technik</option>
<option value="finance-insurance">Finanzen & Versicherungen</option>
<option value="retail-ecommerce">Handel & E-Commerce</option>
<option value="construction-realestate">Bauen & Immobilien</option>
<option value="mobility-logistics">Mobilität & Logistik</option>
<option value="energy-environment">Energie & Umwelt</option>
<option value="medicine-health">Medizin & Gesundheit</option>
<option value="hr-personnel">Personal & HR</option>
<option value="marketing-pr-media">Marketing, PR & Medien</option>
<option value="law-tax">Recht & Steuern</option>
<option value="politics-ngo">Politik, Verbände & NGOs</option>
<option value="science-research">Wissenschaft & Forschung</option>
<option value="lifestyle">Lifestyle</option>
<option value="tourism-culture">Tourismus & Kultur</option>
</select>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- Region -->
<div class="relative">
<select
wire:model.live="region"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
>
<select wire:model.live="region"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
<option value="all">Alle Regionen</option>
<option value="de">Deutschland</option>
<option value="at">Österreich</option>
<option value="ch">Schweiz</option>
</select>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- Sortierung -->
<div class="relative">
<select
wire:model.live="sortBy"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
>
<select wire:model.live="sortBy"
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
<option value="newest">Neueste</option>
<option value="relevance">Relevanz</option>
</select>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
</svg>
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- Active Filters Display -->
@if(count($activeFilters) > 0)
@if (count($activeFilters) > 0)
<div class="flex items-center gap-2 flex-wrap ml-auto">
@foreach($activeFilters as $filter)
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 text-[var(--color-primary)] border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 transition-all hover:bg-[var(--color-primary)]/20 dark:hover:bg-[var(--color-primary)]/30">
@foreach ($activeFilters as $filter)
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 text-[var(--color-primary)] border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 transition-all hover:bg-[var(--color-primary)]/20 dark:hover:bg-[var(--color-primary)]/30">
{{ $filter['label'] }}
<button
wire:click="removeFilter('{{ $filter['key'] }}')"
class="hover:text-[var(--color-primary)]/70 transition-colors"
>
<button wire:click="removeFilter('{{ $filter['key'] }}')"
class="hover:text-[var(--color-primary)]/70 transition-colors">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</span>
@endforeach
<!-- Reset Button -->
<button
wire:click="resetFilters"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<button wire:click="resetFilters"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
Alle zurücksetzen
</button>
</div>

View file

@ -18,54 +18,77 @@ new class extends Component {
}
}; ?>
<div x-data="{ darkMode: $wire.entangle('darkMode') }" x-init="
darkMode = localStorage.getItem('theme') === 'dark';
$watch('darkMode', value => {
localStorage.setItem('theme', value ? 'dark' : 'light');
if (value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
// Initialize theme on load
if (darkMode) {
<div x-data="{ darkMode: $wire.entangle('darkMode') }" x-init="darkMode = localStorage.getItem('theme') === 'dark';
$watch('darkMode', value => {
localStorage.setItem('theme', value ? 'dark' : 'light');
if (value) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
">
<footer class="bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 py-8 mt-auto transition-colors duration-200">
});
// Initialize theme on load
if (darkMode) {
document.documentElement.classList.add('dark');
}">
<footer
class="bg-zinc-100 glow-soft dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 py-8 mt-auto transition-colors duration-200">
<div class="container mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Unternehmen</h3>
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Unternehmen</h3>
<ul class="space-y-2">
<li><a href="/ueber-uns" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Über uns</a></li>
<li><a href="/kontakt" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Kontakt</a></li>
<li><a href="/presse" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Presse</a></li>
<li><a href="/ueber-uns"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Über
uns</a></li>
<li><a href="/kontakt"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Kontakt</a>
</li>
<li><a href="/presse"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Presse</a>
</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Services</h3>
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Services</h3>
<ul class="space-y-2">
<li><a href="/preise" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Preise</a></li>
<li><a href="/newsrooms" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Newsrooms</a></li>
<li><a href="/api" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">API</a></li>
<li><a href="/preise"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Preise</a>
</li>
<li><a href="/newsrooms"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Newsrooms</a>
</li>
<li><a href="/api"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">API</a>
</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Rechtliches</h3>
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Rechtliches</h3>
<ul class="space-y-2">
<li><a href="/impressum" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Impressum</a></li>
<li><a href="/datenschutz" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Datenschutz</a></li>
<li><a href="/agb" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">AGB</a></li>
<li><a href="/impressum"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Impressum</a>
</li>
<li><a href="/datenschutz"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Datenschutz</a>
</li>
<li><a href="/agb"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">AGB</a>
</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Folgen Sie uns</h3>
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Folgen Sie uns</h3>
<ul class="space-y-2">
<li><a href="https://linkedin.com" target="_blank" rel="noopener" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">LinkedIn</a></li>
<li><a href="https://twitter.com" target="_blank" rel="noopener" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Twitter</a></li>
<li><a href="/rss" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">RSS</a></li>
<li><a href="https://linkedin.com" target="_blank" rel="noopener"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">LinkedIn</a>
</li>
<li><a href="https://twitter.com" target="_blank" rel="noopener"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Twitter</a>
</li>
<li><a href="/rss"
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">RSS</a>
</li>
</ul>
</div>
</div>
@ -73,50 +96,84 @@ new class extends Component {
@php
$theme = config('app.theme', 'businessportal24');
$isPresseecho = $theme === 'presseecho';
$isBusinessportal24 = $theme === 'businessportal24';
$currentYear = date('Y');
$siteName = $isPresseecho ? 'Presseecho' : 'Business Portal';
@endphp
<!-- Cross-Link für Presseecho -->
@if($isPresseecho)
<div class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
@if ($isPresseecho)
<div
class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
Für maximale Reichweite?
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Besuchen Sie unser reichweitenstarkes Portal <strong class="text-gray-900 dark:text-gray-100">Businessportal24</strong>
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Besuchen Sie unser reichweitenstarkes Portal <strong
class="text-zinc-900 dark:text-zinc-100">Businessportal24</strong>
</p>
</div>
<a href="https://businessportal24.test"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all whitespace-nowrap">
Zu Businessportal24
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
</div>
</div>
@endif
<div class="pt-6 border-t border-gray-200 dark:border-gray-700">
@if ($isBusinessportal24)
<div
class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
Für maximale Reichweite?
</p>
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Besuchen Sie unser reichweitenstarkes Portal <strong
class="text-zinc-900 dark:text-zinc-100">presseecho</strong>
</p>
</div>
<a href="https://presseecho.test"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all whitespace-nowrap">
Zu Presseecho
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
</div>
</div>
@endif
<div class="pt-6 border-t border-zinc-200 dark:border-zinc-700">
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-sm text-gray-600 dark:text-gray-400">© {{ $currentYear }} {{ $siteName }}. Alle Rechte vorbehalten.</p>
<p class="text-sm text-zinc-600 dark:text-zinc-400">© {{ $currentYear }} {{ $siteName }}. Alle
Rechte vorbehalten.</p>
<!-- Theme Toggle -->
<button
@click="darkMode = !darkMode"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-all"
aria-label="Theme wechseln"
>
<button @click="darkMode = !darkMode"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-lg transition-all"
aria-label="Theme wechseln">
<!-- Sun Icon (zeigt in Dark Mode) -->
<svg x-show="darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-cloak>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
<svg x-show="darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
x-cloak>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
</path>
</svg>
<!-- Moon Icon (zeigt in Light Mode) -->
<svg x-show="!darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
<svg x-show="!darkMode" class="h-5 w-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z">
</path>
</svg>
<span x-text="darkMode ? 'Hell' : 'Dunkel'"></span>
</button>

View file

@ -15,7 +15,7 @@ new class extends Component {
<div>
<header
class="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200/50 dark:border-gray-800/50 shadow-sm backdrop-blur-sm transition-colors duration-200">
class="sticky top-0 z-50 bg-white dark:bg-zinc-900 border-b border-zinc-200/50 dark:border-zinc-800/50 shadow-sm backdrop-blur-sm transition-colors duration-200">
<!-- Brand Accent Bar -->
<div
class="h-1 bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-secondary)] to-[var(--color-primary)]">
@ -26,9 +26,9 @@ new class extends Component {
<div class="flex items-center gap-3">
<!-- Burger Menu Button (immer sichtbar) -->
<button @click="$dispatch('toggle-mobile-menu')"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
class="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-lg transition-colors"
aria-label="Menü öffnen">
<svg class="h-6 w-6 text-gray-900 dark:text-gray-100" fill="none" stroke="currentColor"
<svg class="h-6 w-6 text-zinc-900 dark:text-zinc-100" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
@ -42,7 +42,7 @@ new class extends Component {
// Versuche verschiedene Logo-Namenskonventionen
$logoVariants = [
"images/{$theme}-logo.svg",
"images/" . str_replace('24', '', $theme) . "-logo.svg", // businessportal-logo.svg
'images/' . str_replace('24', '', $theme) . '-logo.svg', // businessportal-logo.svg
];
$logoPath = null;
@ -71,20 +71,20 @@ new class extends Component {
<!-- Search - Desktop -->
<div class="hidden md:flex flex-1 max-w-xl">
<form wire:submit.prevent="search" class="relative w-full">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" fill="none"
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input type="search" wire:model="searchQuery" placeholder="Pressemitteilungen durchsuchen..."
class="pl-10 w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
class="pl-10 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
</form>
</div>
<!-- Search Icon - Mobile -->
<button @click="$wire.showMobileSearch = !$wire.showMobileSearch"
class="md:hidden p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
<svg class="h-5 w-5 text-gray-900 dark:text-gray-100" fill="none" stroke="currentColor"
class="md:hidden p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
<svg class="h-5 w-5 text-zinc-900 dark:text-zinc-100" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
@ -101,17 +101,17 @@ new class extends Component {
@if ($isPresseecho)
<!-- Presseecho: Dezente Navigation -->
<a href="/login"
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
Anmelden
</a>
<a href="/beitrag-einreichen"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors border border-gray-300 dark:border-gray-700">
class="inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-300 dark:border-zinc-700">
Beitrag einreichen
</a>
@else
<!-- Businessportal24: Prominenter CTA -->
<a href="/login"
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
Anmelden
</a>
<a href="/veroeffentlichen"
@ -127,15 +127,15 @@ new class extends Component {
x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="md:hidden border-t border-gray-200 dark:border-gray-800 px-4 py-3 bg-white dark:bg-gray-900" x-cloak>
class="md:hidden border-t border-zinc-200 dark:border-zinc-800 px-4 py-3 bg-white dark:bg-zinc-900" x-cloak>
<form wire:submit.prevent="search" class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500"
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 dark:text-zinc-500"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input type="search" wire:model="searchQuery" placeholder="Pressemitteilungen durchsuchen..."
class="pl-10 w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
class="pl-10 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
</form>
</div>
</header>

View file

@ -0,0 +1,159 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $timeframe = 'all';
#[Locked]
public ?string $portal = null;
protected array $queryString = [
'timeframe' => ['except' => 'all'],
];
public function setTimeframe(string $timeframe): void
{
$this->timeframe = $timeframe;
$this->resetPage();
}
public function with(): array
{
return [
'releases' => $this->pressReleases(),
'totalCount' => $this->totalCount(),
];
}
private function pressReleases(): LengthAwarePaginator
{
return $this->baseQuery()
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereDate('published_at', today()))
->when($this->timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', now()->subDays(7)))
->orderByDesc('published_at')
->paginate(8);
}
private function totalCount(): int
{
return $this->baseQuery()->count();
}
private function baseQuery(): Builder
{
return PressRelease::query()
->with([
'company',
'category.translations' => fn ($query) => $query->where('locale', 'de'),
'images' => fn ($query) => $query
->orderByDesc('is_preview')
->orderBy('sort_order')
->limit(1),
])
->whereIn('portal', $this->portalValues())
->where('status', PressReleaseStatus::Published)
->where('language', 'de')
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
/**
* @return array<int, string>
*/
private function portalValues(): array
{
$primary = $this->portal ?? Portal::Businessportal24->value;
return [$primary, Portal::Both->value];
}
}; ?>
<div wire:loading.class="opacity-60">
<header class="flex items-baseline justify-between mb-4 min-h-[34px] flex-wrap gap-3">
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] leading-[1.2] text-ink">
Aktuelle Meldungen
</h2>
<div class="flex gap-[18px] text-[12.5px] text-ink-3">
<button type="button" wire:click="setTimeframe('all')"
class="cursor-pointer transition-colors @if ($timeframe === 'all') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
Alle
</button>
<button type="button" wire:click="setTimeframe('today')"
class="cursor-pointer transition-colors @if ($timeframe === 'today') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
Heute
</button>
<button type="button" wire:click="setTimeframe('week')"
class="cursor-pointer transition-colors @if ($timeframe === 'week') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
Diese Woche
</button>
</div>
</header>
<hr class="rule-strong">
@php
$items = $releases->items();
$top = $items[0] ?? null;
$rest = array_slice($items, 1);
$mockFeedItems = [
['time' => '13:42', 'date' => '12. Mai', 'category' => 'Tourismus', 'title' => 'Nachhaltiger Tourismus auf Erfolgskurs: Buchungen steigen um 45 %', 'company' => 'GreenTravel Consulting', 'city' => 'Berlin', 'recommended' => true],
['time' => '12:55', 'date' => '12. Mai', 'category' => 'Bildung', 'title' => 'Digitalisierung im Bildungssektor: Schulen erhalten 2 Mrd. Euro Förderung', 'company' => 'EduTech Initiative', 'city' => 'Frankfurt'],
['time' => '11:20', 'date' => '12. Mai', 'category' => 'Medien', 'title' => 'Medienbranche im Umbruch: Streaming-Dienste überholen klassisches TV', 'company' => 'MediaWatch Analytics', 'city' => 'Hamburg'],
['time' => '10:48', 'date' => '12. Mai', 'category' => 'Handel', 'title' => 'Einzelhandel setzt auf KI: Personalisierte Shopping-Erlebnisse werden Standard', 'company' => 'RetailTech Innovations', 'city' => 'Köln', 'recommended' => true],
['time' => '09:33', 'date' => '12. Mai', 'category' => 'Gesundheit', 'title' => 'Telemedizin-Boom: 3 Millionen Online-Sprechstunden im letzten Quartal', 'company' => 'HealthConnect Digital', 'city' => 'Stuttgart'],
['time' => '08:15', 'date' => '12. Mai', 'category' => 'Mobilität', 'title' => 'E-Mobilität: Ladeinfrastruktur wächst um 38 % gegenüber Vorjahr', 'company' => 'eMobility Verband', 'city' => 'Düsseldorf'],
];
$inFeedAd = [
'time' => '12:14',
'date' => now()->translatedFormat('j. MMM'),
'category' => 'Cloud · Software',
'title' => 'Microsoft Azure: Neue EU-Region Frankfurt mit DSGVO-zertifizierter KI-Infrastruktur',
'company' => 'Microsoft Deutschland GmbH',
];
@endphp
@if ($top)
<x-web.feed-top-item :release="$top" />
@else
<x-web.feed-top-item />
@endif
@if (! empty($rest))
@foreach ($rest as $i => $release)
<x-web.feed-item :release="$release" :recommended="in_array($i, [0, 3], true)" />
@endforeach
@else
@foreach ($mockFeedItems as $mock)
<x-web.feed-item :mock="$mock" />
@endforeach
@endif
<x-web.feed-ad :ad="$inFeedAd" />
@if ($releases->hasMorePages())
<div class="flex justify-center mt-7">
<a href="{{ $releases->nextPageUrl() }}"
class="inline-flex items-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold text-ink bg-transparent border border-bg-rule-strong rounded-[2px] cursor-pointer hover:bg-ink hover:text-bg transition-colors">
Weitere {{ number_format(max(0, $totalCount - $releases->currentPage() * $releases->perPage()), 0, ',', '.') }} Meldungen anzeigen
</a>
</div>
@elseif (! $top)
<div class="flex justify-center mt-7">
<a href="{{ route('kategorien') }}"
class="inline-flex items-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold text-ink bg-transparent border border-bg-rule-strong rounded-[2px] cursor-pointer hover:bg-ink hover:text-bg transition-colors">
Alle Rubriken entdecken
</a>
</div>
@endif
</div>

View file

@ -13,7 +13,7 @@ new class extends Component {
'title' => 'KI-Revolution: Deutsche Unternehmen investieren Milliarden in Automatisierung',
'teaser' => 'Eine neue Studie zeigt: Unternehmen im DACH-Raum planen für 2025 Investitionen in Höhe von über 15 Milliarden Euro in KI-gestützte Automatisierungslösungen.',
'company' => 'TechVision Analytics',
'industry' => 'IT & Software',
'industry' => 'IT & Digitalisierung',
'region' => 'Deutschland',
'date' => '17. Okt 2024',
'contentType' => 'ANALYSE',
@ -27,7 +27,7 @@ new class extends Component {
'title' => 'Energiewende beschleunigt sich: Neue Rekorde bei erneuerbaren Energien',
'teaser' => 'Im ersten Quartal 2025 erreicht der Anteil erneuerbarer Energien am Strommix einen historischen Höchststand von 58%.',
'company' => 'GreenPower Deutschland',
'industry' => 'Energie',
'industry' => 'Energie & Umwelt',
'region' => 'Deutschland',
'date' => '16. Okt 2024',
'contentType' => 'FACHMELDUNG',
@ -39,7 +39,7 @@ new class extends Component {
'title' => 'FinTech-Startup sichert sich 45 Millionen Euro in Series-B-Runde',
'teaser' => 'Das Berliner FinTech-Startup PaymentFlow konnte in einer Series-B-Finanzierungsrunde 45 Millionen Euro einsammeln.',
'company' => 'PaymentFlow GmbH',
'industry' => 'Finanzen',
'industry' => 'Finanzen & Versicherungen',
'region' => 'Berlin',
'date' => '15. Okt 2024',
'contentType' => 'FACHMELDUNG',
@ -51,7 +51,7 @@ new class extends Component {
'title' => 'Gesundheitsbranche setzt verstärkt auf digitale Lösungen',
'teaser' => 'Telemedizin und KI-gestützte Diagnostik werden zum Standard: 78% der Krankenhäuser in Deutschland planen Investitionen.',
'company' => 'MediTech Solutions',
'industry' => 'Gesundheit',
'industry' => 'Medizin & Gesundheit',
'region' => 'München',
'date' => '14. Okt 2024',
'contentType' => 'INTERVIEW',
@ -63,7 +63,7 @@ new class extends Component {
'title' => 'Automobilindustrie: Transformation zur E-Mobilität nimmt Fahrt auf',
'teaser' => 'Führende Automobilhersteller kündigen massive Investitionen in E-Mobilität an. Bis 2030 sollen 80% der Neufahrzeuge elektrisch sein.',
'company' => 'Auto Industry Report',
'industry' => 'Automobil',
'industry' => 'Mobilität & Logistik',
'region' => 'Stuttgart',
'date' => '13. Okt 2024',
'contentType' => 'ANALYSE',
@ -76,14 +76,90 @@ new class extends Component {
'title' => 'Cybersecurity: Unternehmen verstärken Schutzmaßnahmen gegen Hackerangriffe',
'teaser' => 'Nach einer Serie von Cyberattacken erhöhen deutsche Unternehmen ihre Investitionen in IT-Sicherheit um durchschnittlich 35%.',
'company' => 'CyberSafe Europe',
'industry' => 'IT & Software',
'industry' => 'IT & Digitalisierung',
'region' => 'Frankfurt',
'date' => '12. Okt 2024',
'contentType' => 'FACHMELDUNG',
'hasImage' => true,
'imageUrl' => 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=800&h=600&fit=crop',
],
]
// Neue Artikel ohne Bild für verschiedene Branchen
[
'slug' => 'immobilienmarkt-trendwende',
'title' => 'Immobilienmarkt 2025: Experten prognostizieren Trendwende bei Kaufpreisen',
'teaser' => 'Nach Jahren steigender Preise zeigen aktuelle Analysen eine Stabilisierung des Immobilienmarktes. Besonders Metropolregionen verzeichnen erstmals rückläufige Tendenzen.',
'company' => 'ImmoConsult Deutschland',
'industry' => 'Bauen & Immobilien',
'region' => 'Hamburg',
'date' => '11. Okt 2024',
'contentType' => 'ANALYSE',
'hasImage' => false,
'hasPdf' => true,
'imageUrl' => null,
],
[
'slug' => 'tourismus-nachhaltigkeit',
'title' => 'Nachhaltiger Tourismus auf Erfolgskurs: Buchungen steigen um 45%',
'teaser' => 'Öko-Hotels und klimaneutrales Reisen liegen im Trend. Die Reisebranche verzeichnet einen deutlichen Wandel im Buchungsverhalten deutscher Urlauber.',
'company' => 'GreenTravel Consulting',
'industry' => 'Tourismus & Kultur',
'region' => 'Österreich',
'date' => '10. Okt 2024',
'contentType' => 'FACHMELDUNG',
'hasImage' => false,
'imageUrl' => null,
],
[
'slug' => 'bildungssektor-digitalisierung',
'title' => 'Digitalisierung im Bildungssektor: Schulen erhalten 2 Milliarden Euro Förderung',
'teaser' => 'Bundesweites Digitalpakt-Programm wird ausgeweitet. Schwerpunkt liegt auf KI-gestützten Lernplattformen und moderner IT-Infrastruktur für Schulen.',
'company' => 'EduTech Initiative',
'industry' => 'Wissenschaft & Forschung',
'region' => 'Berlin',
'date' => '09. Okt 2024',
'contentType' => 'FACHMELDUNG',
'hasImage' => false,
'hasPdf' => true,
'imageUrl' => null,
],
[
'slug' => 'medienbranche-streaming',
'title' => 'Medienbranche im Umbruch: Streaming-Dienste überholen klassisches TV',
'teaser' => 'Erstmals nutzen mehr Deutsche regelmäßig Streaming-Plattformen als lineares Fernsehen. Die Werbebudgets verschieben sich entsprechend.',
'company' => 'MediaWatch Analytics',
'industry' => 'Marketing, PR & Medien',
'region' => 'Köln',
'date' => '08. Okt 2024',
'contentType' => 'ANALYSE',
'hasImage' => false,
'imageUrl' => null,
],
[
'slug' => 'einzelhandel-ki-revolution',
'title' => 'Einzelhandel setzt auf KI: Personalisierte Shopping-Erlebnisse werden Standard',
'teaser' => 'Künstliche Intelligenz revolutioniert den Handel. Von virtuellen Anproben bis zu KI-gestützter Beratung der Einzelhandel wird digital.',
'company' => 'RetailTech Innovations',
'industry' => 'Handel & E-Commerce',
'region' => 'Düsseldorf',
'date' => '07. Okt 2024',
'contentType' => 'INTERVIEW',
'hasImage' => false,
'hasPdf' => true,
'imageUrl' => null,
],
[
'slug' => 'gesundheit-telemedizin-boom',
'title' => 'Telemedizin-Boom: 3 Millionen Online-Sprechstunden im letzten Quartal',
'teaser' => 'Die Akzeptanz von Videosprechstunden erreicht neue Höchststände. Besonders ländliche Regionen profitieren von der digitalen Gesundheitsversorgung.',
'company' => 'HealthConnect Digital',
'industry' => 'Medizin & Gesundheit',
'region' => 'Schweiz',
'date' => '06. Okt 2024',
'contentType' => 'FACHMELDUNG',
'hasImage' => false,
'imageUrl' => null,
],
],
];
}
}; ?>
@ -95,35 +171,16 @@ new class extends Component {
@endphp
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($releases as $release)
@if($isPresseecho)
@foreach ($releases as $release)
@if ($isPresseecho)
{{-- Presseecho: Hochwertige Magazine-Card --}}
<x-web.presseecho-release-card
:title="$release['title']"
:teaser="$release['teaser']"
:company="$release['company']"
:industry="$release['industry']"
:region="$release['region']"
:date="$release['date']"
:contentType="$release['contentType'] ?? 'FACHMELDUNG'"
:slug="$release['slug']"
:imageUrl="$release['imageUrl'] ?? null"
/>
<x-web.press-release-card :title="$release['title']" :teaser="$release['teaser']" :company="$release['company']" :industry="$release['industry']"
:region="$release['region']" :date="$release['date']" :contentType="$release['contentType'] ?? 'FACHMELDUNG'" :slug="$release['slug']" :imageUrl="$release['imageUrl'] ?? null" />
@else
{{-- Businessportal24: Standard-Card --}}
<x-web.press-release-card
:title="$release['title']"
:teaser="$release['teaser']"
:company="$release['company']"
:industry="$release['industry']"
:region="$release['region']"
:date="$release['date']"
:hasImage="$release['hasImage'] ?? false"
:hasPdf="$release['hasPdf'] ?? false"
:slug="$release['slug']"
:imageUrl="$release['imageUrl'] ?? null"
:companyLogo="$release['companyLogo'] ?? null"
/>
<x-web.press-release-card :title="$release['title']" :teaser="$release['teaser']" :company="$release['company']" :industry="$release['industry']"
:region="$release['region']" :date="$release['date']" :hasImage="$release['hasImage'] ?? false" :hasPdf="$release['hasPdf'] ?? false" :slug="$release['slug']"
:imageUrl="$release['imageUrl'] ?? null" :companyLogo="$release['companyLogo'] ?? null" />
@endif
@endforeach
</div>