Optimierung 5.1: Tabellen-Concerns (Sortierung, Volltext-Heuristik, Stats)

- HasSortableTable::sort() ersetzt 5 byte-identische sort()-Kopien in den
  Admin-Listen (Benutzer, Firmen, Kategorien, Kontakte, Pressemitteilungen).
  $sortBy/$sortDir-Defaults bleiben je Komponente, da sie divergieren.
- HasFullTextSearch::supportsFullTextSearch() ersetzt 9 byte-identische Kopien
  (4 Index-Listen + 5 Firmen-Comboboxen); ungenutzte DB-Imports entfernt.
- AdminPerformanceCache::activeInactiveStats(Builder) buendelt den identischen
  SUM(CASE WHEN is_active)-Block der Benutzer- und Firmenliste.

Zurueckgestellt (real divergent): HasResettableFilters (updatedXxxFilter
normalisieren teils zusaetzlich) und eine gemeinsame hydrateRelationCounts().

Tests: erweiterte AdminPerformanceCacheTest; 99 Admin-Tests bleiben gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-19 12:36:48 +00:00
parent b0353abb97
commit 54bab7eff5
15 changed files with 111 additions and 145 deletions

View file

@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\DB;
/**
* Gemeinsame Heuristik, ob für einen Suchbegriff der DB-Volltextindex genutzt
* werden kann: ab drei Zeichen und nur auf MySQL/PostgreSQL (SQLite im Test
* fällt auf die LIKE-Suche zurück). Single Source of Truth für die Admin-Listen
* und Firmen-Comboboxen.
*/
trait HasFullTextSearch
{
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Concerns;
/**
* Spalten-Sortierung für Admin-Tabellen. Erwartet die öffentlichen Properties
* `$sortBy`/`$sortDir` (deren Defaults bleiben bewusst in der jeweiligen
* Komponente, da sie je Tabelle divergieren) sowie `resetPage()` aus
* `Livewire\WithPagination`.
*/
trait HasSortableTable
{
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();
}
}

View file

@ -5,6 +5,7 @@ namespace App\Services\Admin;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
class AdminPerformanceCache
@ -49,6 +50,29 @@ class AdminPerformanceCache
}
}
/**
* Aktiv-/Inaktiv-Kennzahlen einer `is_active`-Tabelle in einer einzigen
* Aggregat-Abfrage. Single Source of Truth für die identischen Stat-Blöcke
* der Benutzer- und Firmen-Liste.
*
* @param Builder<covariant \Illuminate\Database\Eloquent\Model> $query
* @return array{total: int, active: int, inactive: int}
*/
public function activeInactiveStats(Builder $query): array
{
$stats = $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),
];
}
public function pressReleaseReviewCount(): int
{
return (int) $this->remember(self::PressReleaseReviewCount, self::StatsTtl, function (): int {

View file

@ -81,7 +81,7 @@ Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
## Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm)
- [ ] **5.1 Tabellen-Concerns** — `HasSortableTable` (`sort()` 3× wortgleich), `HasFullTextSearch` (`supportsFullTextSearch()` 6×+ + `applyFuzzySearch`), `HasResettableFilters` (`updatedXxxFilter()`→`resetPage`). `stats()` mit `SUM(CASE WHEN is_active)` → `AdminPerformanceCache::activeInactiveStats()`; Count-Hydration → `hydrateRelationCounts()`.
- [x] **5.1 Tabellen-Concerns** — `HasSortableTable::sort()` (5 byte-identische Kopien zentralisiert; `$sortBy`/`$sortDir`-Defaults bleiben in der Komponente, da sie divergieren) und `HasFullTextSearch::supportsFullTextSearch()` (9 byte-identische Kopien — 4 Index-Listen + 5 Firmen-Comboboxen; nicht mehr genutzte `DB`-Imports entfernt). `AdminPerformanceCache::activeInactiveStats(Builder)` bündelt den identischen `SUM(CASE WHEN is_active)`-Block der Benutzer- und Firmenliste. Tests: erweiterte `AdminPerformanceCacheTest`; abgesichert durch 99 `Admin/`-Tests. **Bewusst zurückgestellt:** `HasResettableFilters` (die `updatedXxxFilter()` divergieren — manche normalisieren zusätzlich Werte; ein generischer `updated()`-Hook wäre eine Verhaltensänderung) und eine gemeinsame `hydrateRelationCounts()` (Count-Hydration unterscheidet sich je Tabelle real).
- [ ] **5.2 User create/edit entdoppeln** — `UserFormRules`-Provider + `UserUpsertService`/`SaveUserAction` (`save()` trägt ~175 Zeilen). Markup → `<x-admin.billing-fields>`, `<x-admin.company-link-picker>`, `<x-admin.role-checkboxes>`. Kontakt → `ValidatesContactData`-Concern + `<x-contact-fields>`.
- [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + `<x-portal.portal-badge>`; `<x-portal.status-badge>`; `PressReleaseStatus`-Enum-Methoden; `<x-portal.money>`, `<x-portal.datetime>`, `Category::localizedName()`, `Contact::displayName()`.
- [ ] **5.4 Seiten-Struktur-Components** — `<x-portal.page-header>`, `<x-portal.stat-cards>`, `<x-portal.table-panel>`, `<x-portal.table-pagination>`, `<x-portal.empty-state>`, `<x-portal.searchable-select>`. (Muster: bestehende `<x-portal.stat-card>`/`components.portal.pagination`.)

View file

@ -5,7 +5,6 @@ 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;
@ -13,6 +12,8 @@ use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
@ -21,17 +22,6 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
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();
@ -134,11 +124,6 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
});
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-8">

View file

@ -7,7 +7,6 @@ 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;
@ -16,6 +15,8 @@ use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
@ -42,17 +43,6 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
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'];
@ -124,20 +114,11 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
$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),
];
});
return $cache->remember(
$cache->companiesStatsKey($portal),
AdminPerformanceCache::StatsTtl,
fn (): array => $cache->activeInactiveStats(Company::query()),
);
}
private function hydrateCounts($companies): void
@ -166,12 +147,6 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
});
}
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();

View file

@ -3,13 +3,14 @@
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
{
use \App\Livewire\Concerns\HasFullTextSearch;
public int $id;
public string $activeTab = 'overview';
@ -175,12 +176,6 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
];
}
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) {

View file

@ -3,7 +3,6 @@
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;
@ -11,6 +10,8 @@ use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
public int|string|null $companyId = null;
public string $companySearch = '';
@ -136,11 +137,6 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
];
}
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-8">

View file

@ -3,7 +3,6 @@
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;
@ -12,6 +11,8 @@ use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
#[Locked]
public int $id;
@ -140,12 +141,6 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
];
}
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);

View file

@ -7,7 +7,6 @@ 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;
@ -17,6 +16,8 @@ use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
@ -48,17 +49,6 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
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'
@ -259,12 +249,6 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
});
}
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();
@ -616,7 +600,6 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
$contactCompanyName = $contact->company?->name ?? __('Unbekannte Firma');
@endphp
<flux:table.row :key="$contact->id">
<flux:table.cell>

View file

@ -11,7 +11,6 @@ use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
@ -21,6 +20,8 @@ use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class extends Component
{
use \App\Livewire\Concerns\HandlesAdminPressReleaseActions;
use \App\Livewire\Concerns\HasFullTextSearch;
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
@ -57,17 +58,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
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();
@ -382,11 +372,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->get();
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
}; ?>
<div class="space-y-8">

View file

@ -16,6 +16,7 @@ use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Component
{
use \App\Livewire\Concerns\HasSortableTable;
use WithPagination;
public string $search = '';
@ -36,17 +37,6 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
public ?int $viewingUserId = null;
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', 'portal', 'is_active', 'last_login_at', 'created_at'];
@ -186,20 +176,13 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
*/
private function stats(): array
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::UserStats, AdminPerformanceCache::StatsTtl, function (): array {
$stats = User::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();
$cache = app(AdminPerformanceCache::class);
return [
'total' => (int) ($stats->total ?? 0),
'active' => (int) ($stats->active ?? 0),
'inactive' => (int) ($stats->inactive ?? 0),
];
});
return $cache->remember(
AdminPerformanceCache::UserStats,
AdminPerformanceCache::StatsTtl,
fn (): array => $cache->activeInactiveStats(User::query()),
);
}
private function hydrateCompanyCounts($users): void
@ -576,7 +559,6 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
</flux:text>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>

View file

@ -6,7 +6,6 @@ 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;
@ -15,6 +14,8 @@ use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
public string $name = '';
public string $email = '';
@ -213,12 +214,6 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
->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'])

View file

@ -7,7 +7,6 @@ use App\Models\Contact;
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\Locked;
@ -17,6 +16,8 @@ use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class extends Component
{
use \App\Livewire\Concerns\HasFullTextSearch;
#[Locked]
public int $id;
@ -624,12 +625,6 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
->get(['id', 'name']));
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
protected function syncContactFormsToSelectedCompanies(): void
{
$currentFormsById = collect($this->contactForms)->keyBy('id');

View file

@ -5,6 +5,19 @@ use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Facades\Cache;
use Spatie\Permission\Models\Role;
test('activeInactiveStats aggregates total, active and inactive in one shape', function () {
Company::factory()->count(2)->create(['is_active' => true]);
Company::factory()->create(['is_active' => false]);
$stats = app(AdminPerformanceCache::class)->activeInactiveStats(Company::query());
expect($stats)->toBe([
'total' => 3,
'active' => 2,
'inactive' => 1,
]);
});
test('admin performance cache is flushed when observed models change', function () {
Cache::put(AdminPerformanceCache::DashboardStats, ['stale' => true], 600);
Cache::put(AdminPerformanceCache::PressReleaseStats, ['stale' => true], 600);