WS-8: doppelte Logik konsolidieren (Tags, Kontakt-Query, Billing)

- Themen-Tag-Verwaltung (addTag/removeTag/tagsArray) war in create- und
  edit-Editor byte-identisch dupliziert -> Trait
  App\Livewire\Concerns\InteractsWithPressReleaseTags.
- Kontaktauswahl je Firma (withoutGlobalScopes + where + Sortierung) aus
  create/edit/dashboard in Contact::scopeForCompany() zentralisiert.
- Rechnungsadress-Pflichtfelder als BillingAddress::REQUIRED_FIELDS; isComplete()
  und neues completenessPercent() teilen die Liste, Dashboard nutzt es statt
  eigener Feldliste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-18 13:33:59 +00:00
parent 1ff1cc3626
commit 1aa43914da
6 changed files with 115 additions and 134 deletions

View file

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Concerns;
/**
* Themen-Tag-Verwaltung für die PM-Editoren (Erstellen/Bearbeiten).
*
* Tags werden als kommaseparierte Liste in der string-Property `$keywords`
* gehalten (max. 5, eindeutig). Die nutzende Livewire-Komponente muss eine
* `$keywords`-Property sowie die Computed-Properties `tags` und
* `presubmitChecks` bereitstellen — diese werden nach jeder Änderung
* invalidiert.
*/
trait InteractsWithPressReleaseTags
{
public function addTag(string $tag): void
{
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
}

View file

@ -34,13 +34,27 @@ class BillingAddress extends Model
/** /**
* Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung? * Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung?
* Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land. * Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land.
*
* @var list<string>
*/ */
public const REQUIRED_FIELDS = ['name', 'address1', 'postal_code', 'city', 'country_code'];
public function isComplete(): bool public function isComplete(): bool
{ {
return filled($this->name) return collect(self::REQUIRED_FIELDS)
&& filled($this->address1) ->every(fn (string $field): bool => filled($this->{$field}));
&& filled($this->postal_code) }
&& filled($this->city)
&& filled($this->country_code); /**
* Anteil der ausgefüllten Pflichtfelder in Prozent (0–100) — für die
* Fortschritts-Anzeige im Dashboard.
*/
public function completenessPercent(): int
{
$filled = collect(self::REQUIRED_FIELDS)
->filter(fn (string $field): bool => filled($this->{$field}))
->count();
return (int) round(($filled / count(self::REQUIRED_FIELDS)) * 100);
} }
} }

View file

@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\Portal; use App\Enums\Portal;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use Database\Factories\ContactFactory; use Database\Factories\ContactFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -21,6 +22,19 @@ class Contact extends Model
static::addGlobalScope(new PortalScope); static::addGlobalScope(new PortalScope);
} }
/**
* Kontakte einer Firma, portal-unabhängig (ohne GlobalScope) und in der
* fürs Kunden-Panel üblichen Sortierung. Single Source of Truth für die
* Kontaktauswahl in Erstellen/Bearbeiten und für Kontakt-Zählungen.
*/
public function scopeForCompany(Builder $query, int $companyId): Builder
{
return $query->withoutGlobalScopes()
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name');
}
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'portal', 'portal',

View file

@ -117,14 +117,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
*/ */
private function billingCompleteness(?BillingAddress $address): int private function billingCompleteness(?BillingAddress $address): int
{ {
if (! $address) { return $address?->completenessPercent() ?? 0;
return 0;
}
$fields = ['name', 'address1', 'postal_code', 'city', 'country_code'];
$filled = collect($fields)->filter(fn (string $field) => filled($address->{$field}))->count();
return (int) round(($filled / count($fields)) * 100);
} }
/** /**
@ -166,9 +159,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
} }
if ($selectedCompany) { if ($selectedCompany) {
$contactsCount = Contact::withoutGlobalScopes() $contactsCount = Contact::forCompany($selectedCompany->id)->count();
->where('company_id', $selectedCompany->id)
->count();
if ($contactsCount === 0) { if ($contactsCount === 0) {
$hints[] = [ $hints[] = [

View file

@ -25,6 +25,8 @@ use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
{ {
use \App\Livewire\Concerns\InteractsWithPressReleaseTags;
public string $portal = 'presseecho'; public string $portal = 'presseecho';
public string $language = 'de'; public string $language = 'de';
@ -299,41 +301,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
} }
} }
public function addTag(string $tag): void // Tag-Verwaltung (addTag/removeTag/tagsArray) liegt im Trait
{ // App\Livewire\Concerns\InteractsWithPressReleaseTags.
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
/** /**
* Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und * Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und
@ -662,22 +631,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
]; ];
} }
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
private function defaultContactIdFor(int $companyId): ?int private function defaultContactIdFor(int $companyId): ?int
{ {
@ -689,19 +642,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
*/ */
private function companyContacts(int $companyId): Collection private function companyContacts(int $companyId): Collection
{ {
return Contact::withoutGlobalScopes() return Contact::forCompany($companyId)
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name')
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
} }
private function companyContact(int $contactId, int $companyId): ?Contact private function companyContact(int $contactId, int $companyId): ?Contact
{ {
return Contact::withoutGlobalScopes() return Contact::forCompany($companyId)->whereKey($contactId)->first();
->where('company_id', $companyId)
->whereKey($contactId)
->first();
} }
private function selectedCompany(): ?Company private function selectedCompany(): ?Company

View file

@ -26,6 +26,8 @@ use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{ {
use \App\Livewire\Concerns\InteractsWithPressReleaseTags;
#[Locked] #[Locked]
public int $id; public int $id;
@ -182,41 +184,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->validateScheduledAtWhenReady(); $this->validateScheduledAtWhenReady();
} }
public function addTag(string $tag): void // Tag-Verwaltung (addTag/removeTag/tagsArray) liegt im Trait
{ // App\Livewire\Concerns\InteractsWithPressReleaseTags.
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
/** /**
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag * Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
@ -638,22 +607,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
]; ];
} }
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
private function defaultContactIdFor(int $companyId): ?int private function defaultContactIdFor(int $companyId): ?int
{ {
@ -665,19 +618,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
*/ */
private function companyContacts(int $companyId): Collection private function companyContacts(int $companyId): Collection
{ {
return Contact::withoutGlobalScopes() return Contact::forCompany($companyId)
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name')
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']); ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
} }
private function companyContact(int $contactId, int $companyId): ?Contact private function companyContact(int $contactId, int $companyId): ?Contact
{ {
return Contact::withoutGlobalScopes() return Contact::forCompany($companyId)->whereKey($contactId)->first();
->where('company_id', $companyId)
->whereKey($contactId)
->first();
} }
private function getMyPR(): PressRelease private function getMyPR(): PressRelease