diff --git a/app/Livewire/Concerns/HasFullTextSearch.php b/app/Livewire/Concerns/HasFullTextSearch.php new file mode 100644 index 00000000..e8fc4065 --- /dev/null +++ b/app/Livewire/Concerns/HasFullTextSearch.php @@ -0,0 +1,20 @@ += 3 + && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true); + } +} diff --git a/app/Livewire/Concerns/HasSortableTable.php b/app/Livewire/Concerns/HasSortableTable.php new file mode 100644 index 00000000..4ea4b6c2 --- /dev/null +++ b/app/Livewire/Concerns/HasSortableTable.php @@ -0,0 +1,23 @@ +sortBy === $column) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDir = 'asc'; + } + $this->resetPage(); + } +} diff --git a/app/Services/Admin/AdminPerformanceCache.php b/app/Services/Admin/AdminPerformanceCache.php index 39b94b5a..d8b3e8b9 100644 --- a/app/Services/Admin/AdminPerformanceCache.php +++ b/app/Services/Admin/AdminPerformanceCache.php @@ -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 $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 { diff --git a/dev/optimize/entwicklungsplan.md b/dev/optimize/entwicklungsplan.md index 29707f29..f74669d6 100644 --- a/dev/optimize/entwicklungsplan.md +++ b/dev/optimize/entwicklungsplan.md @@ -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 → ``, ``, ``. Kontakt → `ValidatesContactData`-Concern + ``. - [ ] **5.3 Formatierungs-Components/Enum** — `Portal::badgeColor()` + ``; ``; `PressReleaseStatus`-Enum-Methoden; ``, ``, `Category::localizedName()`, `Contact::displayName()`. - [ ] **5.4 Seiten-Struktur-Components** — ``, ``, ``, ``, ``, ``. (Muster: bestehende ``/`components.portal.pagination`.) diff --git a/resources/views/livewire/admin/categories/index.blade.php b/resources/views/livewire/admin/categories/index.blade.php index 2c7dc0d3..c1c10afa 100644 --- a/resources/views/livewire/admin/categories/index.blade.php +++ b/resources/views/livewire/admin/categories/index.blade.php @@ -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); - } }; ?>
diff --git a/resources/views/livewire/admin/companies/index.blade.php b/resources/views/livewire/admin/companies/index.blade.php index 9bf40e7c..57044dc4 100644 --- a/resources/views/livewire/admin/companies/index.blade.php +++ b/resources/views/livewire/admin/companies/index.blade.php @@ -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(); diff --git a/resources/views/livewire/admin/companies/show.blade.php b/resources/views/livewire/admin/companies/show.blade.php index caea79ea..9c5c1c7b 100644 --- a/resources/views/livewire/admin/companies/show.blade.php +++ b/resources/views/livewire/admin/companies/show.blade.php @@ -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) { diff --git a/resources/views/livewire/admin/contacts/create.blade.php b/resources/views/livewire/admin/contacts/create.blade.php index 0ea99ee1..7a2351cf 100644 --- a/resources/views/livewire/admin/contacts/create.blade.php +++ b/resources/views/livewire/admin/contacts/create.blade.php @@ -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); - } }; ?>
diff --git a/resources/views/livewire/admin/contacts/edit.blade.php b/resources/views/livewire/admin/contacts/edit.blade.php index 17ae29bb..37aa2378 100644 --- a/resources/views/livewire/admin/contacts/edit.blade.php +++ b/resources/views/livewire/admin/contacts/edit.blade.php @@ -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); diff --git a/resources/views/livewire/admin/contacts/index.blade.php b/resources/views/livewire/admin/contacts/index.blade.php index 83296b6c..4e47f6ea 100644 --- a/resources/views/livewire/admin/contacts/index.blade.php +++ b/resources/views/livewire/admin/contacts/index.blade.php @@ -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 - diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index 3f912d1b..64d0ef88 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -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); - } }; ?>
diff --git a/resources/views/livewire/admin/users.blade.php b/resources/views/livewire/admin/users.blade.php index 8aebddfe..11c1f222 100644 --- a/resources/views/livewire/admin/users.blade.php +++ b/resources/views/livewire/admin/users.blade.php @@ -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 - @empty diff --git a/resources/views/livewire/admin/users/create.blade.php b/resources/views/livewire/admin/users/create.blade.php index cd7104fb..650b286e 100644 --- a/resources/views/livewire/admin/users/create.blade.php +++ b/resources/views/livewire/admin/users/create.blade.php @@ -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']) diff --git a/resources/views/livewire/admin/users/edit.blade.php b/resources/views/livewire/admin/users/edit.blade.php index 985fee73..be95eaaf 100644 --- a/resources/views/livewire/admin/users/edit.blade.php +++ b/resources/views/livewire/admin/users/edit.blade.php @@ -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'); diff --git a/tests/Feature/Admin/AdminPerformanceCacheTest.php b/tests/Feature/Admin/AdminPerformanceCacheTest.php index 5595825d..f15a3840 100644 --- a/tests/Feature/Admin/AdminPerformanceCacheTest.php +++ b/tests/Feature/Admin/AdminPerformanceCacheTest.php @@ -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);