12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
219
resources/views/livewire/admin/categories/create.blade.php
Normal file
219
resources/views/livewire/admin/categories/create.blade.php
Normal 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>
|
||||
298
resources/views/livewire/admin/categories/edit.blade.php
Normal file
298
resources/views/livewire/admin/categories/edit.blade.php
Normal 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>
|
||||
296
resources/views/livewire/admin/categories/index.blade.php
Normal file
296
resources/views/livewire/admin/categories/index.blade.php
Normal 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>
|
||||
281
resources/views/livewire/admin/companies/create.blade.php
Normal file
281
resources/views/livewire/admin/companies/create.blade.php
Normal 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>
|
||||
412
resources/views/livewire/admin/companies/edit.blade.php
Normal file
412
resources/views/livewire/admin/companies/edit.blade.php
Normal 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>
|
||||
573
resources/views/livewire/admin/companies/index.blade.php
Normal file
573
resources/views/livewire/admin/companies/index.blade.php
Normal 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>
|
||||
400
resources/views/livewire/admin/companies/show.blade.php
Normal file
400
resources/views/livewire/admin/companies/show.blade.php
Normal 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>
|
||||
275
resources/views/livewire/admin/contacts/create.blade.php
Normal file
275
resources/views/livewire/admin/contacts/create.blade.php
Normal 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>
|
||||
352
resources/views/livewire/admin/contacts/edit.blade.php
Normal file
352
resources/views/livewire/admin/contacts/edit.blade.php
Normal 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>
|
||||
729
resources/views/livewire/admin/contacts/index.blade.php
Normal file
729
resources/views/livewire/admin/contacts/index.blade.php
Normal 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>
|
||||
43
resources/views/livewire/admin/coupons/index.blade.php
Normal file
43
resources/views/livewire/admin/coupons/index.blade.php
Normal 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>
|
||||
201
resources/views/livewire/admin/footer-codes/create.blade.php
Normal file
201
resources/views/livewire/admin/footer-codes/create.blade.php
Normal 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>
|
||||
232
resources/views/livewire/admin/footer-codes/edit.blade.php
Normal file
232
resources/views/livewire/admin/footer-codes/edit.blade.php
Normal 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>
|
||||
240
resources/views/livewire/admin/footer-codes/index.blade.php
Normal file
240
resources/views/livewire/admin/footer-codes/index.blade.php
Normal 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>
|
||||
314
resources/views/livewire/admin/invoices/index.blade.php
Normal file
314
resources/views/livewire/admin/invoices/index.blade.php
Normal 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>
|
||||
171
resources/views/livewire/admin/newsletter/sync.blade.php
Normal file
171
resources/views/livewire/admin/newsletter/sync.blade.php
Normal 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>
|
||||
53
resources/views/livewire/admin/payments/index.blade.php
Normal file
53
resources/views/livewire/admin/payments/index.blade.php
Normal 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>
|
||||
68
resources/views/livewire/admin/portal-switcher.blade.php
Normal file
68
resources/views/livewire/admin/portal-switcher.blade.php
Normal 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>
|
||||
78
resources/views/livewire/admin/presets/create.blade.php
Normal file
78
resources/views/livewire/admin/presets/create.blade.php
Normal 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>
|
||||
99
resources/views/livewire/admin/presets/edit.blade.php
Normal file
99
resources/views/livewire/admin/presets/edit.blade.php
Normal 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>
|
||||
184
resources/views/livewire/admin/presets/index.blade.php
Normal file
184
resources/views/livewire/admin/presets/index.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
290
resources/views/livewire/admin/press-releases/create.blade.php
Normal file
290
resources/views/livewire/admin/press-releases/create.blade.php
Normal 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>
|
||||
480
resources/views/livewire/admin/press-releases/edit.blade.php
Normal file
480
resources/views/livewire/admin/press-releases/edit.blade.php
Normal 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>
|
||||
719
resources/views/livewire/admin/press-releases/index.blade.php
Normal file
719
resources/views/livewire/admin/press-releases/index.blade.php
Normal 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>
|
||||
331
resources/views/livewire/admin/press-releases/show.blade.php
Normal file
331
resources/views/livewire/admin/press-releases/show.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
260
resources/views/livewire/admin/reports/slow-requests.blade.php
Normal file
260
resources/views/livewire/admin/reports/slow-requests.blade.php
Normal 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>
|
||||
134
resources/views/livewire/admin/roles/create.blade.php
Normal file
134
resources/views/livewire/admin/roles/create.blade.php
Normal 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>
|
||||
167
resources/views/livewire/admin/roles/edit.blade.php
Normal file
167
resources/views/livewire/admin/roles/edit.blade.php
Normal 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>
|
||||
92
resources/views/livewire/admin/roles/index.blade.php
Normal file
92
resources/views/livewire/admin/roles/index.blade.php
Normal 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
421
resources/views/livewire/admin/users/create.blade.php
Normal file
421
resources/views/livewire/admin/users/create.blade.php
Normal 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>
|
||||
1224
resources/views/livewire/admin/users/edit.blade.php
Normal file
1224
resources/views/livewire/admin/users/edit.blade.php
Normal file
File diff suppressed because it is too large
Load diff
239
resources/views/livewire/admin/users/show.blade.php
Normal file
239
resources/views/livewire/admin/users/show.blade.php
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue