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