22-05-2026 Optimierung der User und Admin Panels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-22 11:18:59 +02:00
parent d2ba22c0cf
commit e8c47b7553
73 changed files with 10282 additions and 1546 deletions

View file

@ -0,0 +1,218 @@
<?php
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Company;
use Flux\Flux;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class extends Component
{
public string $name = '';
public string $portal = '';
public string $type = '';
public string $address = '';
public string $email = '';
public string $phone = '';
public string $website = '';
public string $countryCode = 'DE';
public bool $disableFooterCode = false;
public function mount(): void
{
$this->type = CompanyType::Company->value;
$this->countryCode = (string) config('countries.default', 'DE');
}
public function save(): void
{
try {
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'portal' => ['required', Rule::in([
Portal::Presseecho->value,
Portal::Businessportal24->value,
Portal::Both->value,
])],
'type' => ['required', Rule::in([CompanyType::Company->value, CompanyType::Agency->value])],
'address' => ['nullable', 'string', 'max:1000'],
'email' => ['nullable', 'email', 'max:190'],
'phone' => ['nullable', 'string', 'max:40'],
'website' => ['nullable', 'url', 'max:190'],
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
]);
} catch (ValidationException $e) {
$count = array_sum(array_map('count', $e->errors()));
Flux::toast(
heading: __('Bitte Eingaben prüfen'),
text: $count > 1
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
variant: 'danger',
duration: 6000,
);
throw $e;
}
$user = auth()->user();
$company = new Company([
'portal' => $validated['portal'],
'owner_user_id' => $user->id,
'type' => $validated['type'],
'name' => $validated['name'],
'address' => $validated['address'] ?: null,
'country_code' => $validated['countryCode'] ?: null,
'email' => $validated['email'] ?: null,
'phone' => $validated['phone'] ?: null,
'website' => $validated['website'] ?: null,
'is_active' => true,
'disable_footer_code' => $this->disableFooterCode,
]);
$company->slug = $company->generateUniqueSlug($validated['name'], [
'portal' => $validated['portal'],
]);
$company->save();
$user->companies()->syncWithoutDetaching([
$company->id => ['role' => 'owner'],
]);
Flux::toast(
heading: __('Firma angelegt'),
text: __('„:name" wurde angelegt und steht sofort zur Verfügung.', ['name' => $company->name]),
variant: 'success',
);
$this->redirect(route('me.press-kits.show', $company->id), navigate: true);
}
public function with(): array
{
return [
'portals' => [
Portal::Presseecho->value => Portal::Presseecho->label(),
Portal::Businessportal24->value => Portal::Businessportal24->label(),
Portal::Both->value => Portal::Both->label(),
],
'types' => [
CompanyType::Company->value => CompanyType::Company->label(),
CompanyType::Agency->value => CompanyType::Agency->label(),
],
'countries' => (array) config('countries.items', []),
];
}
}; ?>
<div class="space-y-6">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Firmen · Anlegen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Firma anlegen') }}
</h1>
<p class="text-[12.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-3)]">
{{ __('Lege Stammdaten und Portal-Zuordnung an. Die Firma steht sofort zur Verfügung — die redaktionelle Prüfung erfolgt erst bei der ersten Pressemitteilung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück zur Liste') }}
</flux:button>
</div>
</header>
<form wire:submit.prevent="save" class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:field class="sm:col-span-2">
<flux:input wire:model="name" :label="__('Firmenname')" required autofocus />
<flux:error name="name" />
</flux:field>
<flux:field>
<flux:select wire:model="portal" :label="__('Portal')" :placeholder="__('Bitte wählen…')" required>
@foreach ($portals as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="portal" />
</flux:field>
<flux:field>
<flux:select wire:model="type" :label="__('Typ')" required>
@foreach ($types as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="type" />
</flux:field>
<flux:field>
<flux:input wire:model="email" :label="__('E-Mail')" type="email" />
<flux:error name="email" />
</flux:field>
<flux:field>
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:error name="phone" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:input wire:model="website" :label="__('Website')" placeholder="https://..." />
<flux:error name="website" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="address" :label="__('Adresse')" rows="3" />
<flux:error name="address" />
</flux:field>
<flux:field>
<flux:select wire:model="countryCode" :label="__('Land')">
@foreach ($countries as $code => $countryName)
<flux:select.option value="{{ $code }}">{{ $countryName }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="countryCode" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
</flux:field>
</div>
</article>
<div class="flex items-center justify-end gap-2">
<flux:button variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">
{{ __('Firma anlegen') }}
</flux:button>
</div>
</form>
</div>

View file

@ -1,8 +1,15 @@
<?php
use App\Models\Company;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
@ -12,39 +19,402 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
public string $search = '';
#[Url(as: 'view', except: 'all')]
public string $savedView = 'all';
#[Url(as: 'portal', except: '')]
public string $portalFilter = '';
#[Url(as: 'role', except: 'all')]
public string $roleFilter = 'all';
#[Url(as: 'mode', except: 'cards')]
public string $viewMode = 'cards';
public function updatedSearch(): void
{
$this->resetPage();
}
public function setSavedView(string $view): void
{
$allowed = ['all', 'active', 'drafts', 'inactive', 'shared'];
$this->savedView = in_array($view, $allowed, true) ? $view : 'all';
$this->resetPage();
}
public function setPortalFilter(string $portal): void
{
$allowed = ['', 'presseecho', 'businessportal24'];
$this->portalFilter = in_array($portal, $allowed, true) ? $portal : '';
$this->resetPage();
}
public function setRoleFilter(string $role): void
{
$allowed = ['all', 'owner', 'responsible', 'member'];
$this->roleFilter = in_array($role, $allowed, true) ? $role : 'all';
$this->resetPage();
}
public function setViewMode(string $mode): void
{
$this->viewMode = $mode === 'list' ? 'list' : 'cards';
}
public function resetFilters(): void
{
$this->search = '';
$this->savedView = 'all';
$this->portalFilter = '';
$this->roleFilter = 'all';
$this->resetPage();
}
/**
* @return Builder<Company>
*/
private function baseQuery(User $user): Builder
{
return app(CustomerCompanyContext::class)
->accessibleCompanyQuery($user);
}
/**
* Wendet die "Saved View"-Logik auf eine Query an.
*
* @param Builder<Company> $query
*/
private function applySavedView(Builder $query, User $user, string $view): void
{
match ($view) {
'active' => $query->where('is_active', true),
'inactive' => $query->where('is_active', false),
'drafts' => $query->whereRaw('1 = 0'),
'shared' => $query->where('owner_user_id', '!=', $user->id),
default => null,
};
}
/**
* @param Builder<Company> $query
*/
private function applySharedFilters(Builder $query): void
{
if (filled($this->portalFilter)) {
$query->where(function ($query) {
$query->where('portal', $this->portalFilter)
->orWhere('portal', 'both');
});
}
if (filled($this->search)) {
$search = trim($this->search);
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('address', 'like', '%'.$search.'%')
->orWhere('slug', 'like', '%'.$search.'%');
});
}
}
/**
* @param Builder<Company> $query
*/
private function applyRoleFilter(Builder $query, User $user, string $role): void
{
if ($role === 'all') {
return;
}
if ($role === 'owner') {
$query->where('owner_user_id', $user->id);
return;
}
$query->where('owner_user_id', '!=', $user->id)
->whereHas('users', function ($query) use ($user, $role): void {
$query->where('users.id', $user->id)
->where('company_user.role', $role);
});
}
/**
* Sammelt alle Counter-Werte in genau drei Queries:
* 1) aggregiertes COUNT/SUM CASE auf companies
* 2) COUNT auf press_releases
* 3) COUNT auf contacts
*
* @return array{
* counters: array{companies: int, active: int, press_releases: int, contacts: int},
* saved_views: array{all: int, active: int, drafts: int, inactive: int, shared: int},
* }
*/
private function buildAggregateCounts(User $user): array
{
$row = $this->baseQuery($user)
->selectRaw(
'COUNT(*) as total_companies,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_companies,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as inactive_companies,
SUM(CASE WHEN owner_user_id <> ? THEN 1 ELSE 0 END) as shared_companies',
[$user->id]
)
->first();
$totalCompanies = (int) ($row->total_companies ?? 0);
$activeCompanies = (int) ($row->active_companies ?? 0);
$inactiveCompanies = (int) ($row->inactive_companies ?? 0);
$sharedCompanies = (int) ($row->shared_companies ?? 0);
if ($totalCompanies === 0) {
return [
'counters' => [
'companies' => 0,
'active' => 0,
'press_releases' => 0,
'contacts' => 0,
],
'saved_views' => [
'all' => 0,
'active' => 0,
'drafts' => 0,
'inactive' => 0,
'shared' => 0,
],
];
}
$companyIdsQuery = $this->baseQuery($user)->select('companies.id');
$pressReleaseCount = (int) \App\Models\PressRelease::query()
->withoutGlobalScopes()
->whereIn('company_id', $companyIdsQuery)
->count();
$contactsCount = (int) \App\Models\Contact::query()
->withoutGlobalScopes()
->whereIn('company_id', $companyIdsQuery)
->count();
return [
'counters' => [
'companies' => $totalCompanies,
'active' => $activeCompanies,
'press_releases' => $pressReleaseCount,
'contacts' => $contactsCount,
],
'saved_views' => [
'all' => $totalCompanies,
'active' => $activeCompanies,
'drafts' => 0,
'inactive' => $inactiveCompanies,
'shared' => $sharedCompanies,
],
];
}
/**
* Bestimmt deterministisch einen Logo-Token (lg-*) anhand der Company-Id.
*/
public function logoVariant(Company $company): string
{
$variants = ['lg-brew', 'lg-mv', 'lg-soft', 'lg-warm'];
if (blank($company->name)) {
return 'lg-blank';
}
return $variants[$company->id % count($variants)];
}
/**
* Initialen aus dem Firmennamen (max. 2 Zeichen, Großbuchstaben).
*/
public function logoInitials(Company $company): string
{
$name = trim((string) $company->name);
if (blank($name)) {
return '';
}
$words = preg_split('/\s+/u', $name) ?: [];
$letters = '';
foreach ($words as $word) {
$first = mb_substr($word, 0, 1);
if ($first !== '') {
$letters .= $first;
}
if (mb_strlen($letters) >= 2) {
break;
}
}
if ($letters === '') {
$letters = mb_substr($name, 0, 2);
}
return mb_strtoupper($letters);
}
/**
* Liefert eine kompakte Meta-Line: Stadt · Typ.
*/
public function metaLine(Company $company): string
{
$parts = [];
$address = trim((string) ($company->address ?? ''));
if (filled($address)) {
$lastLine = collect(preg_split('/\r?\n/', $address))
->map(fn ($line) => trim((string) $line))
->filter()
->last();
if (is_string($lastLine) && filled($lastLine)) {
$parts[] = $lastLine;
}
}
$type = $company->type?->label();
if (is_string($type) && filled($type)) {
$parts[] = $type;
}
return implode(' · ', $parts);
}
/**
* Rolle des aktuellen Users für die Karte (admin|member).
*/
public function userRoleKey(Company $company, User $user): string
{
if ($company->owner_user_id === $user->id) {
return 'owner';
}
return (string) ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
}
public function isAdminRole(string $roleKey): bool
{
return in_array($roleKey, ['owner', 'responsible'], true);
}
public function roleLabel(string $roleKey): string
{
return match ($roleKey) {
'owner' => __('Owner'),
'responsible' => __('Verantwortlich'),
default => __('Mitglied'),
};
}
public function fastLogoUrl(Company $company): ?string
{
if (blank($company->logo_path)) {
return null;
}
$logoPath = trim((string) $company->logo_path);
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
return $logoPath;
}
if (Str::startsWith($logoPath, '/storage/')) {
return asset($logoPath);
}
if (filled($company->legacy_portal)) {
return null;
}
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
return asset('storage/'.ltrim($logoPath, '/'));
}
return null;
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$pressKits = $context->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases'])
->when(filled($this->search), function ($query): void {
$search = trim($this->search);
$query = $this->baseQuery($user)
->select([
'companies.id',
'companies.owner_user_id',
'companies.portal',
'companies.type',
'companies.name',
'companies.address',
'companies.logo_path',
'companies.legacy_portal',
'companies.is_active',
])
->addSelect([
'current_user_role' => DB::table('company_user')
->select('role')
->whereColumn('company_user.company_id', 'companies.id')
->where('company_user.user_id', $user->id)
->limit(1),
])
->withCount([
'contacts' => fn ($q) => $q->withoutGlobalScopes(),
'pressReleases' => fn ($q) => $q->withoutGlobalScopes(),
])
->withMax(['pressReleases' => fn ($q) => $q->withoutGlobalScopes()], 'published_at');
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('slug', 'like', '%'.$search.'%');
});
})
$this->applySavedView($query, $user, $this->savedView);
$this->applySharedFilters($query);
$this->applyRoleFilter($query, $user, $this->roleFilter);
$pressKits = $query
->orderBy('name')
->simplePaginate(24);
->paginate(50)
->through(function (Company $company) use ($user): Company {
$roleKey = $this->userRoleKey($company, $user);
$lastPublishedAt = $company->press_releases_max_published_at
? Carbon::parse($company->press_releases_max_published_at)
: null;
$company->setAttribute('panel_role_key', $roleKey);
$company->setAttribute('panel_is_admin', $this->isAdminRole($roleKey));
$company->setAttribute('panel_role_label', $this->roleLabel($roleKey));
$company->setAttribute('panel_logo_url', $this->fastLogoUrl($company));
$company->setAttribute('panel_logo_variant', $this->logoVariant($company));
$company->setAttribute('panel_logo_initials', $this->logoInitials($company));
$company->setAttribute('panel_meta_line', $this->metaLine($company));
$company->setAttribute(
'panel_last_press_release_short',
$lastPublishedAt?->format('d.m.') ?? '—'
);
$company->setAttribute(
'panel_last_press_release_date',
$lastPublishedAt?->format('d.m.Y') ?? '—'
);
return $company;
});
$aggregates = $this->buildAggregateCounts($user);
return [
'pressKits' => $pressKits,
'context' => $context,
'user' => $user,
'hasActiveFilters' => filled($this->search)
|| $this->savedView !== 'all'
|| filled($this->portalFilter)
|| $this->roleFilter !== 'all',
'counters' => $aggregates['counters'],
'savedViewCounts' => $aggregates['saved_views'],
];
}
}; ?>
<div class="space-y-8">
<div class="space-y-6">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
@ -55,102 +425,486 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Meine Firmen') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}
<div class="counter-strip mt-3">
<span class="seg">
<b>{{ $counters['companies'] }}</b> {{ __('Firmen') }}
</span>
<span class="sep"></span>
<span class="seg is-ok">
<b>{{ $counters['active'] }}</b> {{ __('aktiv') }}
</span>
<span class="sep"></span>
<span class="seg">
<b>{{ $counters['press_releases'] }}</b>
{{ __('Pressemitteilungen gesamt') }}
</span>
<span class="sep"></span>
<span class="seg">
<b>{{ $counters['contacts'] }}</b>
{{ __('Pressekontakte hinterlegt') }}
</span>
</div>
<p class="mt-3 text-[12.5px] leading-[1.55] max-w-[640px] m-0 text-[color:var(--color-ink-3)]">
{{ __('Eine Firma ist der Container für Pressemitteilungen: Stammdaten, Boilerplate, Pressekontakte. Anlage ohne separate Freigabe — die redaktionelle Prüfung erfolgt erst bei der Pressemitteilung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Firma anlegen anfragen') }}
<flux:button variant="ghost" icon="document-arrow-down" disabled>
{{ __('Export') }}
<span class="badge muted ml-2" style="font-size:9px;padding:0 5px;letter-spacing:0.06em;">
{{ __('bald') }}
</span>
</flux:button>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Firma anlegen') }}
</flux:button>
</div>
</header>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
</div>
<div class="p-5">
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
</div>
</article>
{{-- ============== SAVED VIEW TABS ============== --}}
<nav class="view-tabs" aria-label="{{ __('Gespeicherte Ansichten') }}">
@php
$savedViewMeta = [
'all' => __('Alle'),
'active' => __('Aktiv'),
'drafts' => __('In Anlage'),
'inactive' => __('Inaktiv'),
'shared' => __('Mit mir geteilt'),
];
@endphp
{{-- ============== FIRMEN-CARDS ============== --}}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse ($pressKits as $company)
<article class="panel flex flex-col">
<div class="panel-head">
<div class="min-w-0">
<span class="section-eyebrow truncate">{{ $company->name }}</span>
</div>
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@foreach ($savedViewMeta as $key => $label)
<button
type="button"
wire:click="setSavedView('{{ $key }}')"
class="view-tab {{ $savedView === $key ? 'is-active' : '' }}"
data-view="{{ $key }}"
@if ($key === 'drafts') disabled aria-disabled="true" @endif
>
{{ $label }}
<span class="cnt">{{ $savedViewCounts[$key] }}</span>
</button>
@endforeach
</nav>
{{-- ============== FILTER + SUCHE ============== --}}
<section class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<flux:dropdown align="start">
<button type="button" class="filter-chip {{ filled($portalFilter) ? 'is-active' : '' }}">
@if ($portalFilter === 'presseecho')
<span class="dot-pe inline-block"></span>
@elseif ($portalFilter === 'businessportal24')
<span class="dot-bp inline-block"></span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
<span class="dot-pe inline-block" style="margin-right:1px;"></span>
<span class="dot-bp inline-block" style="margin-left:-2px;"></span>
@endif
</div>
{{ __('Portal') }}:
<strong class="font-semibold">
@switch($portalFilter)
@case('presseecho') presseecho @break
@case('businessportal24') businessportal24 @break
@default {{ __('Alle') }}
@endswitch
</strong>
<flux:icon.chevron-down class="size-3 caret" />
</button>
<flux:menu>
<flux:menu.item wire:click="setPortalFilter('')">{{ __('Alle Portale') }}</flux:menu.item>
<flux:menu.item wire:click="setPortalFilter('presseecho')">presseecho</flux:menu.item>
<flux:menu.item wire:click="setPortalFilter('businessportal24')">businessportal24</flux:menu.item>
</flux:menu>
</flux:dropdown>
<div class="p-5 space-y-4 flex-1">
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ $company->slug }}
</div>
<flux:dropdown align="start">
<button type="button" class="filter-chip {{ $roleFilter !== 'all' ? 'is-active' : '' }}">
<flux:icon.user class="size-3 opacity-70" />
{{ __('Rolle') }}:
<strong class="font-semibold">
@switch($roleFilter)
@case('owner') {{ __('Owner') }} @break
@case('responsible') {{ __('Verantwortlich') }} @break
@case('member') {{ __('Mitglied') }} @break
@default {{ __('Alle') }}
@endswitch
</strong>
<flux:icon.chevron-down class="size-3 caret" />
</button>
<flux:menu>
<flux:menu.item wire:click="setRoleFilter('all')">{{ __('Alle Rollen') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('owner')">{{ __('Owner') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('responsible')">{{ __('Verantwortlich') }}</flux:menu.item>
<flux:menu.item wire:click="setRoleFilter('member')">{{ __('Mitglied') }}</flux:menu.item>
</flux:menu>
</flux:dropdown>
<div class="flex flex-wrap gap-2">
<span class="badge hub">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</span>
<span class="badge hub">{{ $context->roleLabelFor($company, $user) }}</span>
@if ($company->disable_footer_code)
<span class="badge warn">{{ __('Footer-Code aus') }}</span>
@endif
</div>
<button type="button" class="filter-chip" disabled aria-disabled="true" title="{{ __('Branche-Filter folgt') }}">
<flux:icon.tag class="size-3 opacity-70" />
{{ __('Branche') }}: <strong class="font-semibold">{{ __('bald') }}</strong>
</button>
<div class="grid grid-cols-2 gap-3 pt-1">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressemitteilungen') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->press_releases_count }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressekontakte') }}
</div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1">
{{ $company->contacts_count }}
</div>
</div>
</div>
</div>
<span class="w-px h-6 bg-[color:var(--color-bg-rule)] mx-1"></span>
<div class="px-5 pb-4 pt-3 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
{{ __('Firma öffnen') }}
</flux:button>
</div>
</article>
@empty
<article class="panel md:col-span-2 xl:col-span-3">
<div class="p-10 flex flex-col items-center justify-center text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.building-office class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Firmen gefunden') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
</p>
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil prüfen') }}
</flux:button>
</div>
</article>
@endforelse
<div class="search-wrap" style="max-width:340px;">
<flux:icon.magnifying-glass class="ico size-3" />
<input
type="search"
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Firmenname, Stadt oder E-Mail…') }}"
/>
</div>
<span class="flex-1"></span>
{{-- View-Toggle Karten/Liste --}}
<div class="seg-toggle" role="tablist" aria-label="{{ __('Ansicht umschalten') }}">
<button
type="button"
wire:click="setViewMode('cards')"
class="cursor-pointer {{ $viewMode === 'cards' ? 'is-active' : '' }}"
aria-label="{{ __('Kartenansicht') }}"
data-viewmode="cards"
>
<flux:icon.squares-2x2 class="size-3" />
{{ __('Karten') }}
</button>
<button
type="button"
wire:click="setViewMode('list')"
class="cursor-pointer {{ $viewMode === 'list' ? 'is-active' : '' }}"
aria-label="{{ __('Listenansicht') }}"
data-viewmode="list"
>
<flux:icon.list-bullet class="size-3" />
{{ __('Liste') }}
</button>
</div>
</div>
</section>
{{ $pressKits->links() }}
{{-- ============== CONTENT-HOST ============== --}}
<article data-state-host>
@if ($pressKits->isEmpty())
{{-- Empty States --}}
@if ($hasActiveFilters)
{{-- Empty: Filter ohne Treffer --}}
<div class="panel" data-state="empty-filter">
<div class="empty-stage">
<div class="empty-ico warm">
<flux:icon.funnel class="size-6" />
</div>
<h3 class="empty-title">{{ __('Keine Firmen mit diesen Filtern') }}</h3>
<p class="empty-sub">
{{ __('Aktive Filter passen auf keine Einträge. Filter zurücksetzen oder weiter fassen.') }}
</p>
<div class="flex items-center gap-2.5 mt-6">
<flux:button variant="primary" wire:click="resetFilters">
{{ __('Alle Filter zurücksetzen') }}
</flux:button>
</div>
</div>
</div>
@else
{{-- Empty: noch keine Firma --}}
<div class="panel" data-state="empty-none">
<div class="empty-stage">
<div class="empty-ico">
<flux:icon.building-office class="size-6" />
</div>
<h3 class="empty-title">{{ __('Noch keine Firma angelegt') }}</h3>
<p class="empty-sub">
{{ __('Lege deine erste Firma an. Du kannst direkt im Anschluss eine Pressemitteilung darauf veröffentlichen — eine separate Freigabe der Firma ist nicht erforderlich.') }}
</p>
<div class="flex items-center gap-2.5 mt-6">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Erste Firma anlegen') }}
</flux:button>
</div>
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">01</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Stammdaten erfassen') }}
</div>
</div>
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">02</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Boilerplate schreiben') }}
</div>
</div>
<div class="text-left px-3 py-2.5 rounded-[3px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">03</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ __('Pressekontakte zuordnen') }}
</div>
</div>
</div>
</div>
</div>
@endif
@elseif ($viewMode === 'cards')
{{-- Karten-Ansicht --}}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-state="cards">
@foreach ($pressKits as $company)
<div
class="firm-card {{ $company->panel_is_admin ? 'is-self' : '' }}"
wire:key="firm-card-{{ $company->id }}"
data-testid="firm-card-{{ $company->id }}"
>
<div class="flex items-start justify-between gap-3">
<div class="logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
@if ($company->panel_logo_url)
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
@else
{{ $company->panel_logo_initials }}
@endif
</div>
<div class="flex items-center gap-1">
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</div>
</div>
<div class="min-w-0">
<h3 class="name">{{ $company->name }}</h3>
@if (filled($company->panel_meta_line))
<div class="meta-line">{{ $company->panel_meta_line }}</div>
@endif
</div>
<div class="flex items-center gap-2 flex-wrap">
@if ($company->portal === \App\Enums\Portal::Both)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@endif
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
{{ $company->panel_role_label }}
</span>
</div>
<div class="kpis">
<div class="kpi">
<span class="k">{{ $company->press_releases_count }}</span>
<span class="l">{{ __('PMs') }}</span>
</div>
<div class="kpi">
<span class="k">{{ $company->contacts_count }}</span>
<span class="l">{{ __('Kontakte') }}</span>
</div>
<div class="kpi">
<span class="k">
{{ $company->panel_last_press_release_short }}
</span>
<span class="l">{{ __('letzte PM') }}</span>
</div>
</div>
<div class="flex items-center gap-2 pt-1">
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="card-action primary" style="flex:1;">
<flux:icon.arrow-right class="size-3" />
{{ __('Firma öffnen') }}
</a>
<a href="{{ route('me.press-releases.create') }}" wire:navigate class="card-action">
<flux:icon.plus class="size-3" />
{{ __('Neue PM') }}
</a>
</div>
</div>
@endforeach
{{-- Add-Tile am Ende des Grids, nur auf der letzten Seite --}}
@if ($pressKits->currentPage() === $pressKits->lastPage())
<a href="{{ route('me.press-kits.create') }}" wire:navigate class="add-tile" data-testid="add-tile">
<span class="ico">
<flux:icon.plus class="size-5" />
</span>
<span class="lbl">{{ __('Neue Firma anlegen') }}</span>
<span class="sub">
{{ __('Stammdaten und Boilerplate. Die Anlage benötigt keine separate Freigabe.') }}
</span>
</a>
@endif
</div>
@else
{{-- Listen-Ansicht --}}
<div class="panel overflow-hidden" data-state="list">
<div class="overflow-x-auto">
<table class="list">
<colgroup>
<col style="width:88px;" />
<col />
<col style="width:190px;" />
<col style="width:140px;" />
<col style="width:110px;" />
<col style="width:80px;" />
<col style="width:100px;" />
<col style="width:130px;" />
<col style="width:56px;" />
</colgroup>
<thead>
<tr>
<th></th>
<th>{{ __('Firma') }}</th>
<th>{{ __('Portal') }}</th>
<th>{{ __('Rolle') }}</th>
<th>{{ __('Status') }}</th>
<th style="text-align:right;">{{ __('PMs') }}</th>
<th style="text-align:right;">{{ __('Kontakte') }}</th>
<th>{{ __('Letzte PM') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($pressKits as $company)
<tr wire:key="firm-row-{{ $company->id }}" data-testid="firm-row-{{ $company->id }}">
<td>
<span class="mini-logo {{ $company->panel_logo_url ? '' : $company->panel_logo_variant }}">
@if ($company->panel_logo_url)
<img src="{{ $company->panel_logo_url }}" alt="{{ $company->name }}" loading="lazy" />
@else
{{ $company->panel_logo_initials }}
@endif
</span>
</td>
<td>
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate class="row-title">
{{ $company->name }}
</a>
@if (filled($company->panel_meta_line))
<div class="row-sub">{{ $company->panel_meta_line }}</div>
@endif
</td>
<td>
<div class="flex flex-wrap items-center gap-1">
@if ($company->portal === \App\Enums\Portal::Both)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@elseif ($company->portal === \App\Enums\Portal::Presseecho)
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
@elseif ($company->portal === \App\Enums\Portal::Businessportal24)
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
@endif
</div>
</td>
<td>
<span class="role-pill {{ $company->panel_is_admin ? 'admin' : '' }}">
{{ $company->panel_role_label }}
</span>
</td>
<td>
@if ($company->is_active)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@else
<span class="badge err dot">{{ __('Inaktiv') }}</span>
@endif
</td>
<td style="text-align:right;">
<span class="row-num">{{ $company->press_releases_count }}</span>
</td>
<td style="text-align:right;">
<span class="row-num">{{ $company->contacts_count }}</span>
</td>
<td>
<span class="row-num">
{{ $company->panel_last_press_release_date }}
</span>
</td>
<td style="text-align:right;">
<div class="firm-list-actions flex items-center justify-end gap-1">
<flux:button
size="sm"
variant="ghost"
icon="eye"
href="{{ route('me.press-kits.show', $company->id) }}"
wire:navigate
aria-label="{{ __('Firma öffnen') }}"
title="{{ __('Firma öffnen') }}"
/>
<flux:button
size="sm"
variant="ghost"
icon="document-plus"
href="{{ route('me.press-releases.create') }}"
wire:navigate
aria-label="{{ __('Neue Pressemitteilung') }}"
title="{{ __('Neue Pressemitteilung') }}"
/>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</article>
{{-- Pagination --}}
@if ($pressKits->hasPages())
<div class="px-1">
{{ $pressKits->links('components.portal.pagination', ['scrollTo' => '[data-state-host]']) }}
</div>
@endif
{{-- ============== ROLLEN-LEGENDE ============== --}}
<article class="panel-warm p-5">
<div class="grid items-start gap-6" style="grid-template-columns:auto 1fr;">
<div class="min-w-[180px]">
<div class="section-eyebrow">{{ __('Rollen pro Firma') }}</div>
<p class="text-[12px] leading-[1.55] mt-3 m-0 max-w-[220px] text-[color:var(--color-ink-3)]">
{{ __('Mehrere Personen können einer Firma zugeordnet sein. Die Rolle steuert, was im Backend möglich ist.') }}
</p>
</div>
<div class="grid gap-4" style="grid-template-columns:repeat(3,1fr);">
<div>
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Owner') }}</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('Stammdaten & Boilerplate') }}</li>
<li>{{ __('Pressekontakte verwalten') }}</li>
<li>{{ __('PMs erstellen, einreichen, archivieren') }}</li>
<li>{{ __('Weitere Mitglieder einladen') }}</li>
</ul>
</div>
<div>
<span class="role-pill admin" style="margin-bottom:8px;">{{ __('Verantwortlich') }}</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('Stammdaten & Boilerplate') }}</li>
<li>{{ __('Pressekontakte verwalten') }}</li>
<li>{{ __('PMs erstellen & einreichen') }}</li>
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Mitglieder-Verwaltung') }}</li>
</ul>
</div>
<div>
<span class="role-pill" style="margin-bottom:8px;">
{{ __('Mitglied') }}
<span class="text-[color:var(--color-ink-4)] font-normal">· {{ __('bald erweitert') }}</span>
</span>
<ul class="text-[11.5px] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5 text-[color:var(--color-ink-2)]">
<li>{{ __('PMs einsehen') }}</li>
<li>{{ __('Stammdaten lesen') }}</li>
<li class="text-[color:var(--color-ink-4)]">{{ __('keine Bearbeitung') }}</li>
</ul>
</div>
</div>
</div>
</article>
</div>