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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
|
@ -37,12 +41,42 @@ new #[Layout('components.layouts.auth')] class extends Component {
|
|||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser = Auth::user();
|
||||
if ($authenticatedUser) {
|
||||
$authenticatedUser->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
Session::regenerate();
|
||||
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
|
||||
public function sendMagicLink(): void
|
||||
{
|
||||
$this->validateOnly('email');
|
||||
|
||||
$user = User::query()->where('email', $this->email)->first();
|
||||
|
||||
if ($user && $user->is_active) {
|
||||
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
|
||||
$loginUrl = route('magic-links.consume', ['token' => $generated['plain_token']]);
|
||||
|
||||
Mail::to($user->email)->send(
|
||||
new MagicLoginLink(
|
||||
user: $user,
|
||||
loginUrl: $loginUrl,
|
||||
expiresAt: $generated['expires_at']->format('d.m.Y H:i')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the authentication request is not rate limited.
|
||||
*/
|
||||
|
|
@ -117,6 +151,15 @@ new #[Layout('components.layouts.auth')] class extends Component {
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<p class="mb-3 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
{{ __('Login without password? Request a one-time email link.') }}
|
||||
</p>
|
||||
<flux:button variant="subtle" wire:click="sendMagicLink" class="w-full">
|
||||
{{ __('Send magic login link') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if (Route::has('register'))
|
||||
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Don\'t have an account?') }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Reusable image manager for a single press release. Used by both the
|
||||
* admin and customer PR edit pages. Authorisation is delegated to the
|
||||
* `update` ability on `PressReleasePolicy`, so the same component is safe
|
||||
* to use for admins (who can always edit) and customers (only their own).
|
||||
*/
|
||||
new class extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
#[Locked]
|
||||
public int $pressReleaseId;
|
||||
|
||||
public $newImage = null;
|
||||
|
||||
public string $newTitle = '';
|
||||
|
||||
public string $newCopyright = '';
|
||||
|
||||
public bool $newIsPreview = false;
|
||||
|
||||
public function mount(int $pressReleaseId): void
|
||||
{
|
||||
$this->pressReleaseId = $pressReleaseId;
|
||||
}
|
||||
|
||||
public function upload(ImageService $imageService): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeImages($pressRelease)) {
|
||||
$this->addError('newImage', __('Bilder können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:'.(int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
|
||||
'newTitle' => ['nullable', 'string', 'max:120'],
|
||||
'newCopyright' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
|
||||
|
||||
if ($this->newIsPreview) {
|
||||
$pressRelease->images()->update(['is_preview' => false]);
|
||||
}
|
||||
|
||||
$pressRelease->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
'title' => $this->newTitle ?: null,
|
||||
'copyright' => $this->newCopyright ?: null,
|
||||
'is_preview' => $this->newIsPreview,
|
||||
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime' => $stored['mime'],
|
||||
]);
|
||||
|
||||
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
|
||||
|
||||
session()->flash('image-status', __('Bild hochgeladen.'));
|
||||
}
|
||||
|
||||
public function setPreview(int $imageId): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
$image = $pressRelease->images()->whereKey($imageId)->first();
|
||||
|
||||
if (! $image) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
|
||||
$image->update(['is_preview' => true]);
|
||||
|
||||
session()->flash('image-status', __('Vorschaubild gesetzt.'));
|
||||
}
|
||||
|
||||
public function moveUp(int $imageId): void
|
||||
{
|
||||
$this->swapSortOrder($imageId, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $imageId): void
|
||||
{
|
||||
$this->swapSortOrder($imageId, 1);
|
||||
}
|
||||
|
||||
public function remove(int $imageId, ImageService $imageService): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeImages($pressRelease)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image = $pressRelease->images()->whereKey($imageId)->first();
|
||||
|
||||
if (! $image) {
|
||||
return;
|
||||
}
|
||||
|
||||
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
|
||||
session()->flash('image-status', __('Bild entfernt.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
|
||||
return [
|
||||
'images' => $pressRelease->images()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get(),
|
||||
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
|
||||
&& $this->canChangeImages($pressRelease),
|
||||
];
|
||||
}
|
||||
|
||||
private function swapSortOrder(int $imageId, int $direction): void
|
||||
{
|
||||
$pressRelease = $this->getPressRelease();
|
||||
$this->authorize('update', $pressRelease);
|
||||
|
||||
if (! $this->canChangeImages($pressRelease)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$images = $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get();
|
||||
$currentIndex = $images->search(fn (PressReleaseImage $image) => $image->id === $imageId);
|
||||
|
||||
if ($currentIndex === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetIndex = $currentIndex + $direction;
|
||||
|
||||
if ($targetIndex < 0 || $targetIndex >= $images->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = $images[$currentIndex];
|
||||
$target = $images[$targetIndex];
|
||||
|
||||
$currentSort = $current->sort_order;
|
||||
$current->update(['sort_order' => $target->sort_order]);
|
||||
$target->update(['sort_order' => $currentSort]);
|
||||
}
|
||||
|
||||
private function getPressRelease(): PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->findOrFail($this->pressReleaseId);
|
||||
}
|
||||
|
||||
private function canChangeImages(PressRelease $pressRelease): bool
|
||||
{
|
||||
if (auth()->user()?->canAccessAdmin()) {
|
||||
return ! in_array(
|
||||
$pressRelease->status,
|
||||
[PressReleaseStatus::Archived],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return in_array(
|
||||
$pressRelease->status,
|
||||
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="md">{{ __('Bilder') }}</flux:heading>
|
||||
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
|
||||
</div>
|
||||
|
||||
@if(session('image-status'))
|
||||
<flux:callout color="green" icon="check-circle" class="mt-3">{{ session('image-status') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
@if($canEdit)
|
||||
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>
|
||||
<flux:input
|
||||
type="file"
|
||||
wire:model="newImage"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:description="__('JPG/PNG/WebP, max. 8 MB. Varianten thumb/medium/large werden automatisch erzeugt.')"
|
||||
/>
|
||||
<flux:error name="newImage" />
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:input wire:model="newTitle" :label="__('Titel (optional)')" />
|
||||
<flux:input wire:model="newCopyright" :label="__('Copyright / Quelle (optional)')" />
|
||||
</div>
|
||||
|
||||
<flux:checkbox wire:model="newIsPreview" :label="__('Als Vorschaubild verwenden')" />
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if($images->isEmpty())
|
||||
<div class="mt-4 rounded-md border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<flux:icon.photo class="mx-auto size-10 text-zinc-400" />
|
||||
<flux:text class="mt-2 text-sm text-zinc-500">{{ __('Noch keine Bilder hinterlegt.') }}</flux:text>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach($images as $image)
|
||||
<div class="group rounded-md border border-zinc-200 p-2 dark:border-zinc-700" wire:key="pri-{{ $image->id }}">
|
||||
<div class="relative aspect-square overflow-hidden rounded bg-zinc-50 dark:bg-zinc-800">
|
||||
@if($image->variantUrl('thumb') ?? $image->url())
|
||||
<img src="{{ $image->variantUrl('thumb') ?? $image->url() }}" alt="{{ $image->title ?? '' }}" class="absolute inset-0 size-full object-cover" loading="lazy" />
|
||||
@endif
|
||||
@if($image->is_preview)
|
||||
<flux:badge color="green" size="sm" icon="star" class="absolute left-2 top-2">
|
||||
{{ __('Vorschau') }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1">
|
||||
@if($image->title)
|
||||
<p class="truncate text-sm font-medium" title="{{ $image->title }}">{{ $image->title }}</p>
|
||||
@endif
|
||||
@if($image->copyright)
|
||||
<p class="truncate text-xs text-zinc-500">{{ $image->copyright }}</p>
|
||||
@endif
|
||||
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
|
||||
@if($image->width && $image->height)
|
||||
<span>{{ $image->width }}×{{ $image->height }}</span>
|
||||
@endif
|
||||
@if(is_array($image->variants))
|
||||
<flux:badge color="zinc" size="xs">{{ count($image->variants) }}× variant</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($canEdit)
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
@if(! $image->is_preview)
|
||||
<flux:button size="xs" variant="ghost" icon="star" wire:click="setPreview({{ $image->id }})" :title="__('Als Vorschau setzen')" />
|
||||
@endif
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $image->id }})" :title="__('Hoch')" />
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $image->id }})" :title="__('Runter')" />
|
||||
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $image->id }})"
|
||||
wire:confirm="{{ __('Bild wirklich entfernen?') }}" :title="__('Entfernen')" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
52
resources/views/livewire/customer/bookings.blade.php
Normal file
52
resources/views/livewire/customer/bookings.blade.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component
|
||||
{
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Buchungen & Add-ons') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Hier werden künftig gebuchte Leistungen, Add-ons und Erweiterungen für Ihre Firmen gebündelt.') }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<flux:badge color="zinc" icon="shopping-bag" size="lg">
|
||||
{{ __('In Vorbereitung') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:callout color="blue" icon="information-circle">
|
||||
{{ __('Der Bereich ist bereits in der Navigation vorbereitet. Buchbare Add-ons werden aktiviert, sobald das Preismodell und die Zahlungslogik final sind.') }}
|
||||
</flux:callout>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<flux:card>
|
||||
<flux:heading size="sm">{{ __('Firmenbezogene Add-ons') }}</flux:heading>
|
||||
<flux:text class="mt-2 text-sm text-zinc-500">
|
||||
{{ __('Zum Beispiel zusätzliche Sichtbarkeit, Verifizierung oder besondere Platzierungen.') }}
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm">{{ __('Credits & Tarif') }}</flux:heading>
|
||||
<flux:text class="mt-2 text-sm text-zinc-500">
|
||||
{{ __('Tarif- und Credit-Informationen folgen, sobald das neue Preismodell live ist.') }}
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm">{{ __('Zahlungsarten') }}</flux:heading>
|
||||
<flux:text class="mt-2 text-sm text-zinc-500">
|
||||
{{ __('Zahlungsarten werden später unter Finanzen eingebunden.') }}
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
91
resources/views/livewire/customer/company-switcher.blade.php
Normal file
91
resources/views/livewire/customer/company-switcher.blade.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public string $activeCompany = 'all';
|
||||
|
||||
public function mount(CustomerCompanyContext $context): void
|
||||
{
|
||||
$companyId = $context->selectedCompanyId(auth()->user());
|
||||
|
||||
$this->activeCompany = $companyId === null ? 'all' : (string) $companyId;
|
||||
}
|
||||
|
||||
public function updatedActiveCompany(CustomerCompanyContext $context): void
|
||||
{
|
||||
if ($this->activeCompany === 'all') {
|
||||
$context->select(auth()->user(), null);
|
||||
} elseif (is_numeric($this->activeCompany)) {
|
||||
$context->select(auth()->user(), (int) $this->activeCompany);
|
||||
}
|
||||
|
||||
$this->redirect($this->redirectTarget(), navigate: false);
|
||||
}
|
||||
|
||||
public function with(CustomerCompanyContext $context): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return [
|
||||
'companies' => $context->companiesFor($user),
|
||||
'selectedCompany' => $context->selectedCompany($user),
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
];
|
||||
}
|
||||
|
||||
private function redirectTarget(): string
|
||||
{
|
||||
return (string) request()->headers->get('referer', route('me.dashboard'));
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
@if($companies->isNotEmpty())
|
||||
<div class="hidden text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400 sm:block">
|
||||
{{ __('Aktive Firma') }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 sm:w-72">
|
||||
<flux:select wire:model.live="activeCompany" size="sm">
|
||||
<option value="all">{{ __('Alle Firmen') }}</option>
|
||||
@foreach($companies as $company)
|
||||
<option value="{{ $company->id }}">
|
||||
{{ $company->name }} · {{ $context->roleLabelFor($company, $user) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<div class="hidden max-w-48 truncate text-xs text-zinc-500 dark:text-zinc-400 lg:block">
|
||||
@if($selectedCompany)
|
||||
{{ $selectedCompany->portal?->label() ?? __('Portal unbekannt') }}
|
||||
@else
|
||||
{{ __('Aggregierte Sicht') }}
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($selectedCompany)
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="building-office"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}"
|
||||
wire:navigate
|
||||
>
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ __('Keine Firma zugeordnet') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
237
resources/views/livewire/customer/dashboard.blade.php
Normal file
237
resources/views/livewire/customer/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component
|
||||
{
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$selectedCompany = $context->selectedCompany($user);
|
||||
|
||||
$pressReleaseQuery = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $user->id)
|
||||
->when($selectedCompanyId !== null, fn ($query) => $query->where('company_id', $selectedCompanyId));
|
||||
|
||||
$myPRs = (clone $pressReleaseQuery)
|
||||
->selectRaw('status, count(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
|
||||
$recent = (clone $pressReleaseQuery)
|
||||
->with('company:id,name')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get(['id', 'title', 'status', 'company_id', 'created_at']);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'selectedCompany' => $selectedCompany,
|
||||
'stats' => [
|
||||
'total' => (clone $pressReleaseQuery)->count(),
|
||||
'published' => $myPRs->get('published', 0),
|
||||
'review' => $myPRs->get('review', 0),
|
||||
'draft' => $myPRs->get('draft', 0),
|
||||
],
|
||||
'qualityHints' => $this->qualityHints($user, $selectedCompany, $pressReleaseQuery),
|
||||
'recent' => $recent,
|
||||
'companies' => $context->companiesFor($user),
|
||||
];
|
||||
}
|
||||
|
||||
private function qualityHints(User $user, ?Company $selectedCompany, Builder $pressReleaseQuery): array
|
||||
{
|
||||
$hints = [];
|
||||
|
||||
if (! $user->profile()->exists()) {
|
||||
$hints[] = [
|
||||
'color' => 'amber',
|
||||
'icon' => 'user',
|
||||
'title' => __('Profil unvollständig'),
|
||||
'description' => __('Ergänzen Sie Ihre Profildaten für eine sauberere Kundenakte.'),
|
||||
'href' => route('me.profile').'#profil',
|
||||
'action' => __('Profil öffnen'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $user->billingAddress()->exists()) {
|
||||
$hints[] = [
|
||||
'color' => 'amber',
|
||||
'icon' => 'archive-box',
|
||||
'title' => __('Rechnungsadresse fehlt'),
|
||||
'description' => __('Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.'),
|
||||
'href' => route('me.profile').'#rechnungsadresse',
|
||||
'action' => __('Rechnungsadresse ergänzen'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($selectedCompany) {
|
||||
$contactsCount = Contact::withoutGlobalScopes()
|
||||
->where('company_id', $selectedCompany->id)
|
||||
->count();
|
||||
|
||||
if ($contactsCount === 0) {
|
||||
$hints[] = [
|
||||
'color' => 'blue',
|
||||
'icon' => 'user-group',
|
||||
'title' => __('Keine Pressekontakte hinterlegt'),
|
||||
'description' => __('Ergänzen Sie Pressekontakte für diese Firma.'),
|
||||
'href' => route('me.press-kits.show', $selectedCompany->id),
|
||||
'action' => __('Firma öffnen'),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$unassignedPressReleasesCount = (clone $pressReleaseQuery)
|
||||
->whereNull('company_id')
|
||||
->count();
|
||||
|
||||
if ($unassignedPressReleasesCount > 0) {
|
||||
$hints[] = [
|
||||
'color' => 'amber',
|
||||
'icon' => 'newspaper',
|
||||
'title' => trans_choice(':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma', $unassignedPressReleasesCount, ['count' => $unassignedPressReleasesCount]),
|
||||
'description' => __('Ordnen Sie Legacy-Pressemitteilungen einer Firma zu, damit Portal und Pressekontakte eindeutig sind.'),
|
||||
'href' => route('me.press-releases.index', ['company' => 'unassigned']),
|
||||
'action' => __('Pressemitteilungen prüfen'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $hints;
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Willkommen, :name', ['name' => $user->name]) }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ $selectedCompany
|
||||
? __('Übersicht für :company', ['company' => $selectedCompany->name])
|
||||
: __('Übersicht Ihres Kundenkontos') }}
|
||||
</flux:subheading>
|
||||
</flux:card>
|
||||
|
||||
{{-- 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>
|
||||
|
||||
@if($qualityHints)
|
||||
<flux:card>
|
||||
<div class="mb-4">
|
||||
<flux:heading size="sm">{{ __('Datenqualität') }}</flux:heading>
|
||||
<flux:text class="text-sm text-zinc-500">{{ __('Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.') }}</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-3">
|
||||
@foreach($qualityHints as $hint)
|
||||
<a href="{{ $hint['href'] }}" wire:navigate class="rounded-lg border border-zinc-200 p-4 transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900">
|
||||
<div class="flex items-start gap-3">
|
||||
<flux:badge color="{{ $hint['color'] }}" size="sm" icon="{{ $hint['icon'] }}" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<flux:text weight="semibold">{{ $hint['title'] }}</flux:text>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">{{ $hint['description'] }}</flux:text>
|
||||
<flux:text class="mt-3 text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $hint['action'] ?? __('Öffnen') }} →
|
||||
</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
|
||||
{{-- Letzte Pressemitteilungen --}}
|
||||
<flux:card class="p-0">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<flux:heading size="sm">{{ __('Meine letzten Pressemitteilungen') }}</flux:heading>
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Alle anzeigen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse($recent as $pr)
|
||||
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
||||
class="flex items-center justify-between gap-3 px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
|
||||
<p class="text-xs text-zinc-500">{{ $pr->company?->name ?? '–' }} · {{ $pr->created_at->format('d.m.Y') }}</p>
|
||||
</div>
|
||||
<flux:badge color="{{ match($pr->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
} }}" size="sm">
|
||||
{{ $pr->status->label() }}
|
||||
</flux:badge>
|
||||
</a>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.newspaper class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Noch keine Pressemitteilungen') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Starten Sie mit einer ersten Pressemitteilung für die aktive Firma oder für Ihr Kundenkonto.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" variant="primary" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Erste Pressemitteilung erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Zugeordnete Firmen --}}
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Meine Firmen') }}</flux:heading>
|
||||
@forelse($companies as $company)
|
||||
<div class="py-2 text-sm">
|
||||
<p class="font-medium">{{ $company->name }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
<div class="mt-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil & Firma verwalten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Neue Pressemitteilung') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
194
resources/views/livewire/customer/invoices.blade.php
Normal file
194
resources/views/livewire/customer/invoices.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
use App\Models\LegacyInvoice;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
public ?string $notification = null;
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$baseQuery = LegacyInvoice::query()
|
||||
->where('user_id', auth()->id());
|
||||
|
||||
$invoices = (clone $baseQuery)
|
||||
->when(filled($this->search), function ($query): void {
|
||||
$query->where('number', 'like', '%'.$this->search.'%');
|
||||
})
|
||||
->when($this->statusFilter !== 'all', fn ($query) => $query->where('status', $this->statusFilter))
|
||||
->latest('invoice_date')
|
||||
->paginate(100);
|
||||
|
||||
return [
|
||||
'invoices' => $invoices,
|
||||
'statusOptions' => (clone $baseQuery)
|
||||
->whereNotNull('status')
|
||||
->distinct()
|
||||
->orderBy('status')
|
||||
->pluck('status')
|
||||
->filter()
|
||||
->values(),
|
||||
'stats' => [
|
||||
'count' => (clone $baseQuery)->count(),
|
||||
'total_cents' => (int) (clone $baseQuery)->sum('total_cents'),
|
||||
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count(),
|
||||
'downloadable_count' => (clone $baseQuery)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Rechnungen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Ihr Rechnungsarchiv im User Backend. PDFs werden bei Bedarf aus den Archivdaten erzeugt.') }}</flux:subheading>
|
||||
</div>
|
||||
|
||||
<flux:badge color="zinc" icon="archive-box" size="lg">
|
||||
{{ __('Archivdaten') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="sm">{{ __('Hinweis zu Rechnungen') }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Aktuell sehen Sie hier die aus dem Legacy-System übernommenen Rechnungen. Neue Abrechnungen werden später in dieselbe Finanznavigation integriert.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<flux:button size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
|
||||
{{ __('Rechnungsadresse im Profil pflegen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
@if($notification)
|
||||
<flux:callout color="yellow" icon="exclamation-triangle">
|
||||
{{ $notification }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Rechnungen') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['count'] }}</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">{{ $stats['paid_count'] }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('PDF-Download') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['downloadable_count'] }}</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Rechnungsnummer suchen…') }}" icon="magnifying-glass" class="flex-1" />
|
||||
<flux:select wire:model.live="statusFilter" class="sm:w-48">
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
@foreach($statusOptions as $status)
|
||||
<option value="{{ $status }}">{{ $status }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</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>{{ __('Betrag') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Bezahlt am') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('PDF') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@forelse($invoices as $invoice)
|
||||
<flux:table.row wire:key="legacy-invoice-{{ $invoice->id }}">
|
||||
<flux:table.cell>
|
||||
<flux:text weight="semibold">{{ $invoice->number ?? ('#'.$invoice->legacy_id) }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" color="zinc">{{ $invoice->legacy_portal?->label() }}</flux:badge>
|
||||
</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>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $invoice->paid_at?->format('d.m.Y') ?? '–' }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="arrow-top-right-on-square"
|
||||
:href="route('me.invoices.pdf', $invoice)"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('Öffnen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="7">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.document-text class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Keine Rechnungen gefunden') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Sobald Rechnungen aus dem Archiv oder aus neuen Buchungen vorhanden sind, erscheinen sie hier.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
|
||||
{{ __('Rechnungsadresse prüfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{ $invoices->links() }}
|
||||
</div>
|
||||
119
resources/views/livewire/customer/press-kits/index.blade.php
Normal file
119
resources/views/livewire/customer/press-kits/index.blade.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
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->where(function ($query) use ($search): void {
|
||||
$query->where('name', 'like', '%'.$search.'%')
|
||||
->orWhere('email', 'like', '%'.$search.'%')
|
||||
->orWhere('slug', 'like', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->simplePaginate(24);
|
||||
|
||||
return [
|
||||
'pressKits' => $pressKits,
|
||||
'context' => $context,
|
||||
'user' => $user,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Meine Firmen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Firmen, Pressekontakte und zugeordnete Pressemitteilungen.') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Firma anlegen anfragen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Firma suchen...') }}" />
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse($pressKits as $company)
|
||||
<flux:card class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<flux:heading size="sm" class="truncate">{{ $company->name }}</flux:heading>
|
||||
<flux:text class="mt-1 text-xs text-zinc-500">{{ $company->slug }}</flux:text>
|
||||
</div>
|
||||
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
|
||||
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:badge color="zinc" size="sm">{{ $company->portal?->label() ?? __('Portal unbekannt') }}</flux:badge>
|
||||
<flux:badge color="indigo" size="sm">{{ $context->roleLabelFor($company, $user) }}</flux:badge>
|
||||
@if($company->disable_footer_code)
|
||||
<flux:badge color="amber" size="sm">{{ __('Footer-Code aus') }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
|
||||
<flux:text size="lg" weight="bold">{{ $company->press_releases_count }}</flux:text>
|
||||
</div>
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
|
||||
<flux:text size="lg" weight="bold">{{ $company->contacts_count }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
</flux:card>
|
||||
@empty
|
||||
<flux:card class="md:col-span-2 xl:col-span-3">
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<flux:icon.building-office class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Keine Firmen gefunden') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Prüfen Sie die Suche oder wenden Sie sich an den Support, wenn eine Firma fehlen sollte.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" variant="primary" href="{{ route('me.profile') }}" wire:navigate>
|
||||
{{ __('Profil prüfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $pressKits->links() }}
|
||||
</div>
|
||||
734
resources/views/livewire/customer/press-kits/show.blade.php
Normal file
734
resources/views/livewire/customer/press-kits/show.blade.php
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Firma')] class extends Component {
|
||||
use WithFileUploads;
|
||||
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public bool $showCompanyForm = false;
|
||||
|
||||
public string $companyName = '';
|
||||
|
||||
public string $companyAddress = '';
|
||||
|
||||
public string $companyEmail = '';
|
||||
|
||||
public string $companyPhone = '';
|
||||
|
||||
public string $companyWebsite = '';
|
||||
|
||||
public string $companyCountryCode = 'DE';
|
||||
|
||||
public bool $companyDisableFooterCode = false;
|
||||
|
||||
public $companyLogo = null;
|
||||
|
||||
public bool $removeCompanyLogo = false;
|
||||
|
||||
public bool $showContactForm = false;
|
||||
|
||||
public ?int $editingContactId = null;
|
||||
|
||||
public string $contactFirstName = '';
|
||||
|
||||
public string $contactLastName = '';
|
||||
|
||||
public string $contactResponsibility = '';
|
||||
|
||||
public string $contactEmail = '';
|
||||
|
||||
public string $contactPhone = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$company = $context->findFor(auth()->user(), $id);
|
||||
|
||||
abort_unless($company !== null, 404);
|
||||
|
||||
$context->select(auth()->user(), $id);
|
||||
}
|
||||
|
||||
public function startEditCompany(): void
|
||||
{
|
||||
$company = $this->company();
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$this->companyName = (string) $company->name;
|
||||
$this->companyAddress = (string) ($company->address ?? '');
|
||||
$this->companyEmail = (string) ($company->email ?? '');
|
||||
$this->companyPhone = (string) ($company->phone ?? '');
|
||||
$this->companyWebsite = (string) ($company->website ?? '');
|
||||
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
|
||||
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
|
||||
$this->companyLogo = null;
|
||||
$this->removeCompanyLogo = false;
|
||||
$this->showCompanyForm = true;
|
||||
}
|
||||
|
||||
public function cancelCompanyForm(): void
|
||||
{
|
||||
$this->resetCompanyForm();
|
||||
}
|
||||
|
||||
public function saveCompany(ImageService $imageService): void
|
||||
{
|
||||
$company = $this->company();
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$validated = $this->validate([
|
||||
'companyName' => ['required', 'string', 'max:255'],
|
||||
'companyAddress' => ['nullable', 'string', 'max:1000'],
|
||||
'companyEmail' => ['nullable', 'email', 'max:190'],
|
||||
'companyPhone' => ['nullable', 'string', 'max:40'],
|
||||
'companyWebsite' => ['nullable', 'url', 'max:190'],
|
||||
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'companyLogo' => ['nullable', 'image', 'max:' . (int) (ImageService::MAX_LOGO_BYTES / 1024)],
|
||||
]);
|
||||
|
||||
$company->fill([
|
||||
'name' => $validated['companyName'],
|
||||
'address' => $validated['companyAddress'] ?: null,
|
||||
'email' => $validated['companyEmail'] ?: null,
|
||||
'phone' => $validated['companyPhone'] ?: null,
|
||||
'website' => $validated['companyWebsite'] ?: null,
|
||||
'country_code' => $validated['companyCountryCode'] ?: null,
|
||||
'disable_footer_code' => $this->companyDisableFooterCode,
|
||||
]);
|
||||
|
||||
if ($this->removeCompanyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
$company->logo_path = null;
|
||||
$company->logo_variants = null;
|
||||
}
|
||||
|
||||
if ($this->companyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
|
||||
$stored = $imageService->storeCompanyLogo($this->companyLogo, $company->portal?->value ?? 'presseecho', $company->id);
|
||||
|
||||
$company->logo_path = $stored['path'];
|
||||
$company->logo_variants = $stored['variants'];
|
||||
}
|
||||
|
||||
$company->save();
|
||||
$this->resetCompanyForm();
|
||||
|
||||
session()->flash('company-status', __('Stammdaten wurden gespeichert.'));
|
||||
}
|
||||
|
||||
public function startCreateContact(): void
|
||||
{
|
||||
$this->authorize('update', $this->company());
|
||||
|
||||
$this->resetContactForm();
|
||||
$this->showContactForm = true;
|
||||
}
|
||||
|
||||
public function editContact(int $contactId): void
|
||||
{
|
||||
$this->authorize('update', $this->company());
|
||||
|
||||
$contact = $this->contact($contactId);
|
||||
|
||||
$this->editingContactId = $contact->id;
|
||||
$this->contactFirstName = (string) ($contact->first_name ?? '');
|
||||
$this->contactLastName = (string) ($contact->last_name ?? '');
|
||||
$this->contactResponsibility = (string) ($contact->responsibility ?? '');
|
||||
$this->contactEmail = (string) ($contact->email ?? '');
|
||||
$this->contactPhone = (string) ($contact->phone ?? '');
|
||||
$this->showContactForm = true;
|
||||
}
|
||||
|
||||
public function cancelContactForm(): void
|
||||
{
|
||||
$this->resetContactForm();
|
||||
}
|
||||
|
||||
public function saveContact(): void
|
||||
{
|
||||
$company = $this->company();
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$validated = $this->validate([
|
||||
'contactFirstName' => ['nullable', 'string', 'max:80'],
|
||||
'contactLastName' => ['nullable', 'string', 'max:80'],
|
||||
'contactResponsibility' => ['nullable', 'string', 'max:255'],
|
||||
'contactEmail' => ['required', 'email', 'max:255'],
|
||||
'contactPhone' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
if (blank($validated['contactFirstName']) && blank($validated['contactLastName'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'contactLastName' => __('Bitte geben Sie mindestens einen Namen an.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal?->value,
|
||||
'first_name' => $validated['contactFirstName'] ?: null,
|
||||
'last_name' => $validated['contactLastName'] ?: null,
|
||||
'responsibility' => $validated['contactResponsibility'] ?: null,
|
||||
'email' => $validated['contactEmail'],
|
||||
'phone' => $validated['contactPhone'] ?: null,
|
||||
];
|
||||
|
||||
if ($this->editingContactId) {
|
||||
$this->contact($this->editingContactId)->update($payload);
|
||||
session()->flash('contact-status', __('Pressekontakt wurde aktualisiert.'));
|
||||
} else {
|
||||
Contact::query()->create($payload);
|
||||
session()->flash('contact-status', __('Pressekontakt wurde angelegt.'));
|
||||
}
|
||||
|
||||
$this->resetContactForm();
|
||||
}
|
||||
|
||||
public function deleteContact(int $contactId): void
|
||||
{
|
||||
$this->authorize('update', $this->company());
|
||||
|
||||
$contact = $this->contact($contactId);
|
||||
$contact->delete();
|
||||
|
||||
if ($this->editingContactId === $contactId) {
|
||||
$this->resetContactForm();
|
||||
}
|
||||
|
||||
session()->flash('contact-status', __('Pressekontakt wurde gelöscht.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$company = $context
|
||||
->accessibleCompanyQuery($user)
|
||||
->withCount(['contacts', 'pressReleases'])
|
||||
->findOrFail($this->id);
|
||||
|
||||
return [
|
||||
'company' => $company,
|
||||
'roleLabel' => $context->roleLabelFor($company, $user),
|
||||
'canManageCompany' => $user->can('update', $company),
|
||||
'canManageContacts' => $user->can('update', $company),
|
||||
'countries' => (array) config('countries.items', []),
|
||||
'contacts' => Contact::withoutGlobalScopes()
|
||||
->where('company_id', $company->id)
|
||||
->withCount('pressReleases')
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->limit(10)
|
||||
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']),
|
||||
'pressReleases' => PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $user->id)
|
||||
->where('company_id', $company->id)
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get(['id', 'title', 'status', 'created_at', 'published_at']),
|
||||
];
|
||||
}
|
||||
|
||||
private function company(): Company
|
||||
{
|
||||
$company = app(CustomerCompanyContext::class)->findFor(auth()->user(), $this->id);
|
||||
|
||||
abort_unless($company !== null, 404);
|
||||
|
||||
return $company;
|
||||
}
|
||||
|
||||
private function contact(int $contactId): Contact
|
||||
{
|
||||
return Contact::withoutGlobalScopes()->where('company_id', $this->id)->findOrFail($contactId);
|
||||
}
|
||||
|
||||
private function resetCompanyForm(): void
|
||||
{
|
||||
$this->showCompanyForm = false;
|
||||
$this->companyName = '';
|
||||
$this->companyAddress = '';
|
||||
$this->companyEmail = '';
|
||||
$this->companyPhone = '';
|
||||
$this->companyWebsite = '';
|
||||
$this->companyCountryCode = 'DE';
|
||||
$this->companyDisableFooterCode = false;
|
||||
$this->companyLogo = null;
|
||||
$this->removeCompanyLogo = false;
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
private function resetContactForm(): void
|
||||
{
|
||||
$this->showContactForm = false;
|
||||
$this->editingContactId = null;
|
||||
$this->contactFirstName = '';
|
||||
$this->contactLastName = '';
|
||||
$this->contactResponsibility = '';
|
||||
$this->contactEmail = '';
|
||||
$this->contactPhone = '';
|
||||
$this->resetValidation();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@if ($company->logoUrl())
|
||||
<img src="{{ $company->logoUrl() }}" alt="{{ $company->name }}" width="64" height="64"
|
||||
class="h-20 max-h-20 w-20 max-w-20 rounded-xl object-contain p-2" />
|
||||
@else
|
||||
<flux:icon.building-office class="size-8 text-zinc-400" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:heading size="xl">{{ $company->name }}</flux:heading>
|
||||
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
|
||||
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">{{ $company->slug }}</flux:text>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<flux:badge color="zinc" size="sm">
|
||||
{{ $company->portal?->label() ?? __('Portal unbekannt') }}</flux:badge>
|
||||
<flux:badge color="indigo" size="sm">{{ $roleLabel }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ __('Pressemappe') }}</flux:badge>
|
||||
@if ($company->disable_footer_code)
|
||||
<flux:badge color="amber" size="sm">{{ __('Footer-Code deaktiviert') }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button icon="plus" variant="primary" href="{{ route('me.press-releases.create') }}"
|
||||
wire:navigate>
|
||||
{{ __('Neue Pressemitteilung') }}
|
||||
</flux:button>
|
||||
@if ($canManageCompany)
|
||||
<flux:button icon="pencil" variant="ghost" wire:click="startEditCompany">
|
||||
{{ __('Stammdaten bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button icon="arrow-left" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button size="sm" variant="ghost" href="#stammdaten">{{ __('Stammdaten') }}</flux:button>
|
||||
<flux:button size="sm" variant="ghost" href="#pressekontakte">{{ __('Pressekontakte') }}</flux:button>
|
||||
<flux:button size="sm" variant="ghost" href="#pressemitteilungen">{{ __('Pressemitteilungen') }}</flux:button>
|
||||
<flux:button size="sm" variant="ghost" href="#abrechnung">{{ __('Abrechnung') }}</flux:button>
|
||||
<flux:button size="sm" variant="ghost" href="#statistik">{{ __('Statistik') }}</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<flux:card>
|
||||
<flux:text class="text-xs 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-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $company->contacts_count }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Portal') }}</flux:text>
|
||||
<flux:text weight="bold">{{ $company->portal?->label() ?? '–' }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Deine Rolle') }}</flux:text>
|
||||
<flux:text weight="bold">{{ $roleLabel }}</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<flux:card id="stammdaten">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Stammdaten') }}</flux:heading>
|
||||
<flux:text class="text-sm text-zinc-500">{{ __('Firmendaten dieser Firma.') }}</flux:text>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge color="{{ $company->is_active ? 'green' : 'red' }}" size="sm">
|
||||
{{ $company->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
@if ($canManageCompany)
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="startEditCompany">
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('company-status'))
|
||||
<flux:callout color="green" icon="check-circle" class="mb-4">
|
||||
{{ session('company-status') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if ($showCompanyForm)
|
||||
<div class="mb-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:heading size="sm" class="mb-4">
|
||||
{{ __('Stammdaten bearbeiten') }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:input wire:model="companyName" :label="__('Firmenname')" required />
|
||||
<flux:error name="companyName" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="companyEmail" :label="__('E-Mail')" type="email" />
|
||||
<flux:error name="companyEmail" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="companyPhone" :label="__('Telefon')" />
|
||||
<flux:error name="companyPhone" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="companyWebsite" :label="__('Website')" placeholder="https://..." />
|
||||
<flux:error name="companyWebsite" />
|
||||
</flux:field>
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:textarea wire:model="companyAddress" :label="__('Adresse')" rows="3" />
|
||||
<flux:error name="companyAddress" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:select wire:model="companyCountryCode" :label="__('Land')">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="companyCountryCode" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model="companyDisableFooterCode"
|
||||
:label="__('Footer-Code deaktivieren')" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-4" />
|
||||
|
||||
<div class="space-y-3">
|
||||
<flux:heading size="xs">{{ __('Firmenlogo') }}</flux:heading>
|
||||
@php($logoUrl = $company->logoUrl())
|
||||
@if ($logoUrl && !$removeCompanyLogo)
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ $logoUrl }}" alt="{{ $company->name }}" width="64"
|
||||
height="64"
|
||||
class="h-16 max-h-16 w-16 max-w-16 rounded-md border border-zinc-200 object-contain dark:border-zinc-700" />
|
||||
<flux:button type="button" size="sm" variant="ghost"
|
||||
wire:click="$set('removeCompanyLogo', true)">
|
||||
{{ __('Logo entfernen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
<flux:field>
|
||||
<flux:input type="file" wire:model="companyLogo" :label="__('Neues Logo hochladen')"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
:description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')" />
|
||||
<flux:error name="companyLogo" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button type="button" variant="ghost" wire:click="cancelCompanyForm">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="button" variant="primary" wire:click="saveCompany">
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('E-Mail') }}</flux:text>
|
||||
<flux:text>{{ $company->email ?: '–' }}</flux:text>
|
||||
</div>
|
||||
<div>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Telefon') }}</flux:text>
|
||||
<flux:text>{{ $company->phone ?: '–' }}</flux:text>
|
||||
</div>
|
||||
<div>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Website') }}</flux:text>
|
||||
@if ($company->website)
|
||||
<a href="{{ $company->website }}" target="_blank"
|
||||
class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ $company->website }}</a>
|
||||
@else
|
||||
<flux:text>–</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Land') }}</flux:text>
|
||||
<flux:text>{{ $company->country_code ?: '–' }}</flux:text>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Adresse') }}</flux:text>
|
||||
<flux:text>{{ $company->address ?: '–' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card id="pressekontakte">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressekontakte') }}</flux:heading>
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ trans_choice(':count Kontakt|:count Kontakte', $company->contacts_count, ['count' => $company->contacts_count]) }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
@if ($canManageContacts)
|
||||
<flux:button size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
|
||||
{{ __('Kontakt hinzufügen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (session('contact-status'))
|
||||
<flux:callout color="green" icon="check-circle" class="mb-4">
|
||||
{{ session('contact-status') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if ($showContactForm)
|
||||
<div class="mb-4 rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:heading size="sm" class="mb-4">
|
||||
{{ $editingContactId ? __('Pressekontakt bearbeiten') : __('Neuen Pressekontakt anlegen') }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:input wire:model="contactFirstName" :label="__('Vorname')" />
|
||||
<flux:error name="contactFirstName" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="contactLastName" :label="__('Nachname')" />
|
||||
<flux:error name="contactLastName" />
|
||||
</flux:field>
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:input wire:model="contactResponsibility" :label="__('Position / Rolle')" />
|
||||
<flux:error name="contactResponsibility" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="contactEmail" :label="__('E-Mail')" type="email" required />
|
||||
<flux:error name="contactEmail" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="contactPhone" :label="__('Telefon')" />
|
||||
<flux:error name="contactPhone" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<flux:button type="button" variant="ghost" wire:click="cancelContactForm">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="button" variant="primary" wire:click="saveContact">
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-3">
|
||||
@forelse($contacts as $contact)
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<flux:text weight="semibold">
|
||||
{{ trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
|
||||
</flux:text>
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 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
|
||||
@if ($contact->press_releases_count > 0)
|
||||
<span>{{ trans_choice('in :count Pressemitteilung hinterlegt|in :count Pressemitteilungen hinterlegt', $contact->press_releases_count, ['count' => $contact->press_releases_count]) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($canManageContacts)
|
||||
<div class="flex gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="editContact({{ $contact->id }})">
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="deleteContact({{ $contact->id }})"
|
||||
wire:confirm="{{ __('Diesen Pressekontakt löschen?') }}">
|
||||
{{ __('Löschen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
|
||||
<flux:text weight="semibold">{{ __('Keine Pressekontakte hinterlegt') }}</flux:text>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Pressekontakte helfen, Pressemitteilungen eindeutig einer Ansprechperson zuzuordnen.') }}
|
||||
</flux:text>
|
||||
@if ($canManageContacts)
|
||||
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" wire:click="startCreateContact">
|
||||
{{ __('Kontakt hinzufügen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card id="pressemitteilungen" class="p-0">
|
||||
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<flux:heading size="lg">{{ __('Pressemitteilungen dieser Firma') }}</flux:heading>
|
||||
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Alle anzeigen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Titel') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Datum') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@forelse($pressReleases as $pressRelease)
|
||||
<flux:table.row wire:key="company-pr-{{ $pressRelease->id }}">
|
||||
<flux:table.cell>
|
||||
<flux:text weight="semibold">{{ $pressRelease->title }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge
|
||||
color="{{ match ($pressRelease->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
} }}"
|
||||
size="sm">
|
||||
{{ $pressRelease->status->label() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '–') }}
|
||||
</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="ghost" icon="eye"
|
||||
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
|
||||
{{ __('Öffnen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="4">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.newspaper class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">
|
||||
{{ __('Keine Pressemitteilungen für diese Firma') }}
|
||||
</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Erstellen Sie die erste Pressemitteilung direkt mit dieser Firma als Kontext.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Neue Pressemitteilung') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<flux:card id="abrechnung">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Abrechnung') }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Firmenspezifische Zahlungsarten und Add-ons werden hier später zusammengeführt.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<flux:badge color="zinc" size="sm">{{ __('In Vorbereitung') }}</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border border-dashed border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<flux:text weight="semibold">{{ __('Noch keine firmenspezifische Abrechnung') }}</flux:text>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Rechnungen finden Sie aktuell gesammelt im Finanzbereich. Firmenscharfe Zahlungsarten folgen mit dem Preismodell.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" size="sm" variant="ghost" href="{{ route('me.invoices.index') }}" wire:navigate>
|
||||
{{ __('Rechnungen öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card id="statistik">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Statistik') }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Erste Kennzahlen zur Firma; detaillierte Auswertungen folgen später.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<flux:badge color="zinc" size="sm">{{ __('Später') }}</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Pressemitteilungen') }}</flux:text>
|
||||
<flux:text size="lg" weight="bold">{{ $company->press_releases_count }}</flux:text>
|
||||
</div>
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Pressekontakte') }}</flux:text>
|
||||
<flux:text size="lg" weight="bold">{{ $company->contacts_count }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
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 int|string|null $categoryId = null;
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $text = '';
|
||||
|
||||
public string $keywords = '';
|
||||
|
||||
public string $backlinkUrl = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$firstCompany = $context->selectedCompany($user) ?? $context->companiesFor($user)->first();
|
||||
|
||||
if ($firstCompany) {
|
||||
$this->companyId = $firstCompany->id;
|
||||
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
|
||||
}
|
||||
}
|
||||
|
||||
public function save(string $submitStatus = 'draft'): void
|
||||
{
|
||||
$this->validate([
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'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'],
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if (! $company) {
|
||||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||
|
||||
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : 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' => $user->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,
|
||||
]);
|
||||
|
||||
session()->flash('success', $status === PressReleaseStatus::Review
|
||||
? __('Pressemitteilung zur Prüfung eingereicht.')
|
||||
: __('Entwurf gespeichert.'));
|
||||
|
||||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$myCompanies = $context->companiesFor($user);
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'myCompanies' => $myCompanies,
|
||||
'categories' => $categories,
|
||||
'selectedPortalLabel' => $this->selectedCompany()?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedCompanyId(): void
|
||||
{
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if ($company?->portal) {
|
||||
$this->portal = $company->portal->value;
|
||||
}
|
||||
}
|
||||
|
||||
private function selectedCompany(): ?Company
|
||||
{
|
||||
return app(CustomerCompanyContext::class)
|
||||
->findFor(auth()->user(), (int) $this->companyId);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm: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('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
|
||||
<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" 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…') }}" />
|
||||
<flux:error name="text" />
|
||||
</flux:field>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Firma') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:select wire:model="companyId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach($myCompanies as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||
@endforeach
|
||||
</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:input :label="__('Portal')" :value="$selectedPortalLabel" disabled />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="space-y-2">
|
||||
<flux:button type="button" variant="primary" class="w-full" wire:click="save('review')">
|
||||
{{ __('Zur Prüfung einreichen') }}
|
||||
</flux:button>
|
||||
<flux:button type="button" variant="ghost" class="w-full" wire:click="save('draft')">
|
||||
{{ __('Als Entwurf speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
231
resources/views/livewire/customer/press-releases/edit.blade.php
Normal file
231
resources/views/livewire/customer/press-releases/edit.blade.php
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
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 int|string|null $categoryId = null;
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $text = '';
|
||||
|
||||
public string $keywords = '';
|
||||
|
||||
public string $backlinkUrl = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('update', $pr);
|
||||
|
||||
abort_unless(
|
||||
in_array($pr->status->value, ['draft', 'rejected']),
|
||||
403,
|
||||
__('Nur Entwürfe und abgelehnte Pressemitteilungen können bearbeitet werden.')
|
||||
);
|
||||
|
||||
$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 ?? '';
|
||||
}
|
||||
|
||||
public function updatedCompanyId(): void
|
||||
{
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if ($company?->portal) {
|
||||
$this->portal = $company->portal->value;
|
||||
}
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'companyId' => ['required', 'integer'],
|
||||
'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 = $this->getMyPR();
|
||||
$this->authorize('update', $pr);
|
||||
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if (! $company) {
|
||||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||
|
||||
$pr->update([
|
||||
'portal' => $this->portal,
|
||||
'language' => $this->language,
|
||||
'company_id' => (int) $this->companyId,
|
||||
'category_id' => (int) $this->categoryId,
|
||||
'title' => $this->title,
|
||||
'text' => $this->text,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
]);
|
||||
|
||||
session()->flash('success', __('Pressemitteilung gespeichert.'));
|
||||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$myCompanies = $user->companies()->orderBy('name')->get(['companies.id', 'companies.name', 'companies.portal']);
|
||||
$selectedCompany = $this->selectedCompany();
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'myCompanies' => $myCompanies,
|
||||
'categories' => $categories,
|
||||
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMyPR(): PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', auth()->id())
|
||||
->findOrFail($this->id);
|
||||
}
|
||||
|
||||
private function selectedCompany(): ?Company
|
||||
{
|
||||
return auth()->user()
|
||||
->companies()
|
||||
->whereKey((int) $this->companyId)
|
||||
->first(['companies.id', 'companies.portal']);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Inhalt und Metadaten der Pressemitteilung aktualisieren.') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
|
||||
<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>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Stichwörter') }}</flux:label>
|
||||
<flux:input wire:model="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>
|
||||
|
||||
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Firma') }}</flux:label>
|
||||
<flux:select wire:model="companyId">
|
||||
@foreach($myCompanies as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||
@endforeach
|
||||
</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:input :label="__('Portal')" :value="$selectedPortalLabel" disabled />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
201
resources/views/livewire/customer/press-releases/index.blade.php
Normal file
201
resources/views/livewire/customer/press-releases/index.blade.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
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('Meine Pressemitteilungen')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
#[Url(as: 'company', except: 'all')]
|
||||
public string $companyFilter = '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 updatedSearch(): void { $this->resetPage(); }
|
||||
|
||||
public function updatedStatusFilter(): void { $this->resetPage(); }
|
||||
|
||||
public function updatedCompanyFilter(): void { $this->resetPage(); }
|
||||
|
||||
public function submitForReview(int $id): void
|
||||
{
|
||||
$pr = $this->findMyPR($id);
|
||||
if (! $pr) { return; }
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$userId = auth()->id();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
|
||||
|
||||
$prs = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $userId)
|
||||
->with('company:id,name')
|
||||
->when($selectedCompanyId !== null, fn ($q) => $q->where('company_id', $selectedCompanyId))
|
||||
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn ($q) => $q->whereNotNull('company_id'))
|
||||
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn ($q) => $q->whereNull('company_id'))
|
||||
->when(filled($this->search), function ($q): void {
|
||||
$term = $this->search;
|
||||
$q->where('title', 'like', '%'.$term.'%');
|
||||
})
|
||||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||||
->orderBy(in_array($this->sortBy, ['title', 'status', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
|
||||
->paginate(100);
|
||||
|
||||
return [
|
||||
'pressReleases' => $prs,
|
||||
'statusOptions' => PressReleaseStatus::cases(),
|
||||
'selectedCompany' => $context->selectedCompany(auth()->user()),
|
||||
'hasGlobalCompanyContext' => $selectedCompanyId === null,
|
||||
];
|
||||
}
|
||||
|
||||
private function findMyPR(int $id): ?PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->where('id', $id)
|
||||
->where('user_id', auth()->id())
|
||||
->first();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<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">{{ __('Meine Pressemitteilungen') }}</flux:heading>
|
||||
@if($selectedCompany)
|
||||
<flux:subheading>{{ __('Gefiltert auf :company', ['company' => $selectedCompany->name]) }}</flux:subheading>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Neue Pressemitteilung') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Titel suchen…') }}" icon="magnifying-glass" class="flex-1" />
|
||||
<flux:select wire:model.live="statusFilter" class="sm:w-44">
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
@foreach($statusOptions as $s)
|
||||
<option value="{{ $s->value }}">{{ $s->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@if($hasGlobalCompanyContext)
|
||||
<flux:select wire:model.live="companyFilter" class="sm:w-48">
|
||||
<option value="all">{{ __('Alle Firmenzuordnungen') }}</option>
|
||||
<option value="assigned">{{ __('Mit Firma') }}</option>
|
||||
<option value="unassigned">{{ __('Ohne Firma') }}</option>
|
||||
</flux:select>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-0">
|
||||
<div class="p-4">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column sortable :sorted="$sortBy==='title'" :direction="$sortDir" wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Firma') }}</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==='created_at'" :direction="$sortDir" wire:click="sort('created_at')">{{ __('Erstellt') }}</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>
|
||||
<p class="max-w-xs truncate font-medium">{{ $pr->title }}</p>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm">{{ $pr->company?->name ?? '–' }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="{{ match($pr->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
} }}">{{ $pr->status->label() }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y') }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="eye" href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
|
||||
@if(in_array($pr->status->value, ['draft', 'rejected']))
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
||||
<flux:button size="sm" variant="ghost" icon="paper-airplane" wire:click="submitForReview({{ $pr->id }})"
|
||||
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}" />
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.newspaper class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Keine Pressemitteilungen gefunden') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Passen Sie die Filter an oder erstellen Sie eine neue Pressemitteilung.') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Neue Pressemitteilung') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{ $pressReleases->links() }}
|
||||
</div>
|
||||
323
resources/views/livewire/customer/press-releases/show.blade.php
Normal file
323
resources/views/livewire/customer/press-releases/show.blade.php
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
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 $shareUrl = null;
|
||||
|
||||
public ?string $shareExpiresAt = null;
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
}
|
||||
|
||||
public function submitForReview(): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('submitForReview', $pr);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Pressemitteilung wurde automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung zur Prüfung eingereicht.'));
|
||||
}
|
||||
|
||||
public function generateShareLink(MagicLinkGenerator $generator): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
$share = $generator->createPressReleaseShareLink($pr, auth()->user());
|
||||
|
||||
$this->shareUrl = $share['url'];
|
||||
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
|
||||
|
||||
session()->flash('success', __('Vorschau-Link wurde erzeugt.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '–';
|
||||
|
||||
$latestRejection = null;
|
||||
if ($pr->status->value === 'rejected') {
|
||||
$latestRejection = $pr->statusLogs
|
||||
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
|
||||
}
|
||||
|
||||
return [
|
||||
'pr' => $pr,
|
||||
'categoryName' => $categoryName,
|
||||
'canEdit' => auth()->user()->can('update', $pr)
|
||||
&& in_array($pr->status->value, ['draft', 'rejected']),
|
||||
'latestRejection' => $latestRejection,
|
||||
'contacts' => $pr->contacts,
|
||||
'statusLogs' => $pr->statusLogs,
|
||||
'statusColor' => match($pr->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private function getMyPR(): PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', auth()->id())
|
||||
->with([
|
||||
'company:id,name,email,phone',
|
||||
'category.translations',
|
||||
'contacts' => fn ($query) => $query
|
||||
->withoutGlobalScopes()
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
|
||||
'statusLogs.changedBy:id,name,email',
|
||||
])
|
||||
->findOrFail($this->id);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ $pr->company?->name ?? '–' }} · {{ $categoryName }} · {{ $pr->created_at->format('d.m.Y') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@if($canEdit)
|
||||
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
|
||||
{{ __('Vorschau-Link') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($shareUrl)
|
||||
<div class="mt-4 rounded-md border border-emerald-300 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
|
||||
<flux:heading size="sm" class="mb-2">{{ __('Öffentlicher Vorschau-Link erstellt') }}</flux:heading>
|
||||
<flux:text class="mb-2 text-xs text-zinc-500">{{ __('Gültig bis :date.', ['date' => $shareExpiresAt]) }}</flux:text>
|
||||
<flux:input readonly :value="$shareUrl" />
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
@if($pr->status === PressReleaseStatus::Rejected && $latestRejection)
|
||||
<flux:callout color="red" icon="exclamation-triangle">
|
||||
<flux:callout.heading>{{ __('Diese Pressemitteilung wurde abgelehnt') }}</flux:callout.heading>
|
||||
<flux:callout.text>
|
||||
@if($latestRejection->reason)
|
||||
<strong>{{ __('Begründung') }}:</strong>
|
||||
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
|
||||
@else
|
||||
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
|
||||
@endif
|
||||
<span class="mt-2 block text-xs text-red-700/70 dark:text-red-300/70">
|
||||
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected
|
||||
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
|
||||
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
|
||||
</flux:text>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($canEdit)
|
||||
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button type="button" variant="primary" wire:click="submitForReview"
|
||||
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
@if($pr->status === PressReleaseStatus::Review)
|
||||
<flux:callout color="yellow" icon="clock">
|
||||
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Zugeordnete Pressekontakte') }}</flux:heading>
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
@if($pr->company)
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
|
||||
{{ __('Firma') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@forelse($contacts as $contact)
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text weight="semibold">
|
||||
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
|
||||
</flux:text>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 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>
|
||||
@empty
|
||||
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
|
||||
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
|
||||
@if($pr->company)
|
||||
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate class="font-medium text-blue-600 hover:underline dark:text-blue-400">
|
||||
{{ __('Kontakte in der Firma prüfen.') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Status & Verlauf') }}</flux:heading>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Aktueller Status') }}</flux:text>
|
||||
<flux:badge class="mt-1" :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
|
||||
</div>
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Erstellt') }}</flux:text>
|
||||
<flux:text weight="semibold">{{ $pr->created_at?->format('d.m.Y H:i') ?? '–' }}</flux:text>
|
||||
</div>
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Veröffentlicht') }}</flux:text>
|
||||
<flux:text weight="semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}</flux:text>
|
||||
</div>
|
||||
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Aufrufe') }}</flux:text>
|
||||
<flux:text weight="semibold">{{ number_format($pr->hits, 0, ',', '.') }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-4" />
|
||||
|
||||
@if($statusLogs->isNotEmpty())
|
||||
<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() }}</flux:badge>
|
||||
<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">
|
||||
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($log->reason)
|
||||
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@else
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ __('Noch keine Statusänderungen protokolliert.') }}
|
||||
</flux:text>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card>
|
||||
<div class="prose prose-zinc dark:prose-invert max-w-none">
|
||||
{!! nl2br(e($pr->text)) !!}
|
||||
</div>
|
||||
|
||||
@if($pr->keywords || $pr->backlink_url)
|
||||
<div class="mt-6 space-y-2 border-t border-zinc-200 pt-4 text-sm text-zinc-500 dark:border-zinc-700">
|
||||
@if($pr->keywords)
|
||||
<p><strong>{{ __('Stichwörter') }}:</strong> {{ $pr->keywords }}</p>
|
||||
@endif
|
||||
@if($pr->backlink_url)
|
||||
<p><strong>{{ __('Backlink') }}:</strong>
|
||||
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline">{{ $pr->backlink_url }}</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
</div>
|
||||
451
resources/views/livewire/customer/profile.blade.php
Normal file
451
resources/views/livewire/customer/profile.blade.php
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Profile;
|
||||
use App\Models\User;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $language = 'de';
|
||||
|
||||
public string $salutationKey = 'none';
|
||||
|
||||
public string $firstName = '';
|
||||
|
||||
public string $lastName = '';
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $address = '';
|
||||
|
||||
public string $countryCode = 'DE';
|
||||
|
||||
public string $backlinkUrl = '';
|
||||
|
||||
public bool $showStats = false;
|
||||
|
||||
public bool $disableFooterCode = false;
|
||||
|
||||
public string $taxIdNumber = '';
|
||||
|
||||
public string $billingName = '';
|
||||
|
||||
public string $billingAddress1 = '';
|
||||
|
||||
public string $billingAddress2 = '';
|
||||
|
||||
public string $billingPostalCode = '';
|
||||
|
||||
public string $billingCity = '';
|
||||
|
||||
public string $billingCountryCode = 'DE';
|
||||
|
||||
public ?int $editableCompanyId = null;
|
||||
|
||||
public string $companyName = '';
|
||||
|
||||
public string $companyAddress = '';
|
||||
|
||||
public string $companyEmail = '';
|
||||
|
||||
public string $companyPhone = '';
|
||||
|
||||
public string $companyWebsite = '';
|
||||
|
||||
public string $companyCountryCode = 'DE';
|
||||
|
||||
public bool $companyDisableFooterCode = false;
|
||||
|
||||
public $companyLogo = null;
|
||||
|
||||
public bool $removeCompanyLogo = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$profile = $user->profile;
|
||||
|
||||
$this->name = (string) $user->name;
|
||||
$this->language = $user->language ?? 'de';
|
||||
|
||||
$this->salutationKey = (string) ($profile->salutation_key ?? 'none');
|
||||
$this->firstName = (string) ($profile?->first_name ?? '');
|
||||
$this->lastName = (string) ($profile?->last_name ?? '');
|
||||
$this->title = (string) ($profile?->title ?? '');
|
||||
$this->phone = (string) ($profile?->phone ?? '');
|
||||
$this->address = (string) ($profile?->address ?? '');
|
||||
$this->countryCode = (string) ($profile?->country_code ?? 'DE');
|
||||
$this->backlinkUrl = (string) ($profile?->backlink_url ?? '');
|
||||
$this->showStats = (bool) ($profile?->show_stats ?? false);
|
||||
$this->disableFooterCode = (bool) ($profile?->disable_footer_code ?? false);
|
||||
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
|
||||
|
||||
$billingAddress = $user->billingAddress;
|
||||
$this->billingName = (string) ($billingAddress?->name ?? $user->name);
|
||||
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
|
||||
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
|
||||
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
|
||||
$this->billingCity = (string) ($billingAddress?->city ?? '');
|
||||
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
|
||||
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function selectCompany(int $companyId): void
|
||||
{
|
||||
$this->editableCompanyId = $companyId;
|
||||
$this->loadEditableCompany();
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
|
||||
'firstName' => ['nullable', 'string', 'max:80'],
|
||||
'lastName' => ['nullable', 'string', 'max:80'],
|
||||
'title' => ['nullable', 'string', 'max:80'],
|
||||
'phone' => ['nullable', 'string', 'max:40'],
|
||||
'address' => ['nullable', 'string', 'max:1000'],
|
||||
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'taxIdNumber' => ['nullable', 'string', 'max:255'],
|
||||
'billingName' => ['nullable', 'string', 'max:255'],
|
||||
'billingAddress1' => ['nullable', 'string', 'max:255'],
|
||||
'billingAddress2' => ['nullable', 'string', 'max:255'],
|
||||
'billingPostalCode' => ['nullable', 'string', 'max:20'],
|
||||
'billingCity' => ['nullable', 'string', 'max:120'],
|
||||
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
]);
|
||||
|
||||
if ($this->billingHasInput() && ! $this->billingIsComplete()) {
|
||||
throw ValidationException::withMessages([
|
||||
'billingName' => __('Bitte füllen Sie für eine Rechnungsadresse Name, Adresse, PLZ, Ort und Land aus.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$user->forceFill([
|
||||
'name' => $validated['name'],
|
||||
'language' => $validated['language'],
|
||||
])->save();
|
||||
|
||||
$user->profile()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'salutation_key' => $validated['salutationKey'],
|
||||
'first_name' => $validated['firstName'] ?: null,
|
||||
'last_name' => $validated['lastName'] ?: null,
|
||||
'title' => $validated['title'] ?: null,
|
||||
'phone' => $validated['phone'] ?: null,
|
||||
'address' => $validated['address'] ?: null,
|
||||
'country_code' => $validated['countryCode'] ?: null,
|
||||
'backlink_url' => $validated['backlinkUrl'] ?: null,
|
||||
'show_stats' => $this->showStats,
|
||||
'disable_footer_code' => $this->disableFooterCode,
|
||||
'tax_id_number' => $validated['taxIdNumber'] ?: null,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $this->billingHasInput()) {
|
||||
$user->billingAddress()->delete();
|
||||
} else {
|
||||
$user->billingAddress()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'salutation_key' => $validated['salutationKey'] !== 'none' ? $validated['salutationKey'] : null,
|
||||
'title' => $validated['title'] ?: null,
|
||||
'name' => $validated['billingName'],
|
||||
'address1' => $validated['billingAddress1'],
|
||||
'address2' => $validated['billingAddress2'] ?: null,
|
||||
'postal_code' => $validated['billingPostalCode'],
|
||||
'city' => $validated['billingCity'],
|
||||
'country_code' => $validated['billingCountryCode'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
session()->flash('profile-status', __('Profil gespeichert.'));
|
||||
}
|
||||
|
||||
public function saveCompany(ImageService $imageService): void
|
||||
{
|
||||
if (! $this->editableCompanyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$company = $this->resolveEditableCompany($this->editableCompanyId);
|
||||
|
||||
if (! $company) {
|
||||
throw ValidationException::withMessages([
|
||||
'companyName' => __('Diese Firma kann von Ihnen nicht bearbeitet werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->authorize('update', $company);
|
||||
|
||||
$validated = $this->validate([
|
||||
'companyName' => ['required', 'string', 'max:255'],
|
||||
'companyAddress' => ['nullable', 'string', 'max:1000'],
|
||||
'companyEmail' => ['nullable', 'email', 'max:190'],
|
||||
'companyPhone' => ['nullable', 'string', 'max:40'],
|
||||
'companyWebsite' => ['nullable', 'url', 'max:190'],
|
||||
'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'companyLogo' => ['nullable', 'image', 'max:'.(int) (ImageService::MAX_LOGO_BYTES / 1024)],
|
||||
]);
|
||||
|
||||
$company->fill([
|
||||
'name' => $validated['companyName'],
|
||||
'address' => $validated['companyAddress'] ?: null,
|
||||
'email' => $validated['companyEmail'] ?: null,
|
||||
'phone' => $validated['companyPhone'] ?: null,
|
||||
'website' => $validated['companyWebsite'] ?: null,
|
||||
'country_code' => $validated['companyCountryCode'] ?: null,
|
||||
'disable_footer_code' => $this->companyDisableFooterCode,
|
||||
]);
|
||||
|
||||
if ($this->removeCompanyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
$company->logo_path = null;
|
||||
$company->logo_variants = null;
|
||||
}
|
||||
|
||||
if ($this->companyLogo) {
|
||||
$imageService->deleteCompanyLogo($company->logo_path, $company->logo_variants);
|
||||
|
||||
$stored = $imageService->storeCompanyLogo(
|
||||
$this->companyLogo,
|
||||
$company->portal?->value ?? 'presseecho',
|
||||
$company->id,
|
||||
);
|
||||
|
||||
$company->logo_path = $stored['path'];
|
||||
$company->logo_variants = $stored['variants'];
|
||||
}
|
||||
|
||||
$company->save();
|
||||
|
||||
$this->companyLogo = null;
|
||||
$this->removeCompanyLogo = false;
|
||||
|
||||
session()->flash('company-status', __('Firmendaten gespeichert.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$companies = $user->companies()
|
||||
->withPivot('role')
|
||||
->orderBy('name')
|
||||
->get(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id']);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'companies' => $companies,
|
||||
'salutations' => collect((array) config('salutations.items', []))
|
||||
->map(fn (array $labels) => $labels[$user->language] ?? $labels['de'] ?? '')
|
||||
->all(),
|
||||
'countries' => (array) config('countries.items', []),
|
||||
'editableCompany' => $this->editableCompanyId
|
||||
? $this->resolveEditableCompany($this->editableCompanyId)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadEditableCompany(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$editable = Company::query()
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->orderBy('name');
|
||||
|
||||
$company = $this->editableCompanyId
|
||||
? $editable->whereKey($this->editableCompanyId)->first()
|
||||
: $editable->first();
|
||||
|
||||
if (! $company) {
|
||||
$this->editableCompanyId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editableCompanyId = $company->id;
|
||||
$this->companyName = (string) $company->name;
|
||||
$this->companyAddress = (string) ($company->address ?? '');
|
||||
$this->companyEmail = (string) ($company->email ?? '');
|
||||
$this->companyPhone = (string) ($company->phone ?? '');
|
||||
$this->companyWebsite = (string) ($company->website ?? '');
|
||||
$this->companyCountryCode = (string) ($company->country_code ?? 'DE');
|
||||
$this->companyDisableFooterCode = (bool) $company->disable_footer_code;
|
||||
}
|
||||
|
||||
private function resolveEditableCompany(int $companyId): ?Company
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
return Company::query()
|
||||
->where('id', $companyId)
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('users', fn ($q) => $q->whereKey($user->id)
|
||||
->whereIn('company_user.role', ['owner', 'responsible']));
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
public function billingHasInput(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
|| filled($this->billingAddress1)
|
||||
|| filled($this->billingAddress2)
|
||||
|| filled($this->billingPostalCode)
|
||||
|| filled($this->billingCity);
|
||||
}
|
||||
|
||||
public function billingIsComplete(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
&& filled($this->billingAddress1)
|
||||
&& filled($this->billingPostalCode)
|
||||
&& filled($this->billingCity)
|
||||
&& filled($this->billingCountryCode);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Mein Profil') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Hier pflegen Sie Ihre persönlichen Konto- und Profildaten. Firmendaten verwalten Sie direkt in der jeweiligen Firma.') }}
|
||||
</flux:subheading>
|
||||
</flux:card>
|
||||
|
||||
@if(session('profile-status'))
|
||||
<flux:callout color="green" icon="check-circle">{{ session('profile-status') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
<form wire:submit="saveProfile" class="grid gap-6 lg:grid-cols-2">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-4">{{ __('Konto') }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="name" :label="__('Name')" required />
|
||||
<flux:input value="{{ $user->email }}" :label="__('E-Mail')" disabled :description="__('Änderung über Konto-Sicherheit möglich.')" />
|
||||
<flux:select wire:model="language" :label="__('Sprache')">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card id="profil">
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<flux:badge color="indigo" size="sm">{{ __('Profil') }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ __('Rechnungsadresse') }}</flux:badge>
|
||||
</div>
|
||||
|
||||
<flux:heading size="sm" class="mb-4">{{ __('Profil') }}</flux:heading>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:checkbox wire:model="showStats" :label="__('Statistiken in Pressemitteilungen anzeigen')" class="sm:col-span-2" />
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code in Pressemitteilungen deaktivieren')" class="sm:col-span-2" />
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-6" />
|
||||
|
||||
<flux:heading id="rechnungsadresse" size="sm" class="mb-2">{{ __('Rechnungsadresse') }}</flux:heading>
|
||||
<flux:text class="mb-4 text-sm text-zinc-500">
|
||||
{{ __('Diese Angaben werden für künftige Rechnungen verwendet. Eine vollständige Rechnungsadresse benötigt Name, Adresse, PLZ, Ort und Land.') }}
|
||||
</flux:text>
|
||||
|
||||
@if(! $this->billingIsComplete())
|
||||
<flux:callout color="amber" icon="exclamation-triangle" class="mb-4">
|
||||
{{ __('Rechnungsadresse noch unvollständig. Bitte ergänzen Sie die Pflichtangaben, bevor neue Buchungen sauber abgerechnet werden können.') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingAddress1" :label="__('Adresse Zeile 1')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingAddress2" :label="__('Adresse Zeile 2')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
|
||||
<flux:input wire:model="billingCity" :label="__('Ort')" />
|
||||
<flux:select wire:model="billingCountryCode" :label="__('Land')">
|
||||
@foreach($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="taxIdNumber" :label="__('USt-ID')" />
|
||||
<flux:error name="billingName" class="sm:col-span-2" />
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="lg:col-span-2 flex justify-end">
|
||||
<flux:button type="submit" variant="primary">{{ __('Profil speichern') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-4">{{ __('Zugeordnete Firmen') }}</flux:heading>
|
||||
|
||||
@forelse($companies as $company)
|
||||
<div class="flex flex-col gap-2 border-b border-zinc-100 py-3 last:border-0 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-sm">{{ $company->name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<flux:badge color="zinc" size="sm">{{ $company->portal?->label() ?? '–' }}</flux:badge>
|
||||
<flux:badge color="indigo" size="sm">{{ $company->pivot->role ?? 'member' }}</flux:badge>
|
||||
@if($company->owner_user_id === $user->id)
|
||||
<flux:badge color="green" size="sm">{{ __('Eigentümer') }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if($company->owner_user_id === $user->id || in_array($company->pivot->role, ['owner', 'responsible'], true))
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma verwalten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="sm" variant="ghost" icon="arrow-right" href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate>
|
||||
{{ __('Firma öffnen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ __('Keine Firmen zugeordnet. Bitte wenden Sie sich an den Administrator.') }}
|
||||
</flux:text>
|
||||
@endforelse
|
||||
</flux:card>
|
||||
</div>
|
||||
295
resources/views/livewire/customer/security.blade.php
Normal file
295
resources/views/livewire/customer/security.blade.php
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Laravel\Fortify\Features;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
|
||||
{
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public bool $confirmedTwoFactor = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$this->email = (string) $user->email;
|
||||
$this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null);
|
||||
}
|
||||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'current_password' => ['required', 'string', 'current_password'],
|
||||
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->forceFill([
|
||||
'password' => Hash::make($validated['password']),
|
||||
])->save();
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
session()->flash('security-status', __('Passwort aktualisiert.'));
|
||||
}
|
||||
|
||||
public function updateEmail(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:190',
|
||||
Rule::unique(User::class, 'email')->ignore(auth()->id()),
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$user->forceFill([
|
||||
'email' => $validated['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
if (Features::enabled(Features::emailVerification())) {
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
session()->flash('security-status', __('E-Mail-Adresse aktualisiert. Bitte erneut bestätigen, falls eine Verifizierung verschickt wurde.'));
|
||||
}
|
||||
|
||||
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
|
||||
{
|
||||
$enable(auth()->user());
|
||||
|
||||
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung aktiviert. Scannen Sie den QR-Code mit Ihrer Authenticator-App.'));
|
||||
}
|
||||
|
||||
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void
|
||||
{
|
||||
$disable(auth()->user());
|
||||
$this->confirmedTwoFactor = false;
|
||||
|
||||
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
|
||||
}
|
||||
|
||||
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
|
||||
{
|
||||
$generate(auth()->user());
|
||||
|
||||
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
$user->refresh();
|
||||
|
||||
$qrUrl = null;
|
||||
$recoveryCodes = [];
|
||||
|
||||
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
|
||||
try {
|
||||
$qrUrl = $user->twoFactorQrCodeSvg();
|
||||
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$qrUrl = null;
|
||||
$recoveryCodes = [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null),
|
||||
'twoFactorQrSvg' => $qrUrl,
|
||||
'recoveryCodes' => $recoveryCodes,
|
||||
'sessions' => DB::table('sessions')
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('last_activity')
|
||||
->limit(5)
|
||||
->get(['id', 'ip_address', 'user_agent', 'last_activity']),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Konto-Sicherheit') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Passwort, E-Mail und Zwei-Faktor-Authentifizierung verwalten.') }}
|
||||
</flux:subheading>
|
||||
</flux:card>
|
||||
|
||||
@if(session('security-status'))
|
||||
<flux:callout color="green" icon="check-circle">{{ session('security-status') }}</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('E-Mail') }}</flux:text>
|
||||
<flux:text weight="bold" class="mt-1 truncate">{{ $user->email }}</flux:text>
|
||||
<flux:badge class="mt-3" color="{{ $user->email_verified_at ? 'green' : 'amber' }}" size="sm">
|
||||
{{ $user->email_verified_at ? __('Bestätigt') : __('Nicht bestätigt') }}
|
||||
</flux:badge>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Zwei-Faktor') }}</flux:text>
|
||||
<flux:text weight="bold" class="mt-1">
|
||||
{{ $twoFactorEnabled ? __('Aktiv') : __('Nicht aktiv') }}
|
||||
</flux:text>
|
||||
<flux:badge class="mt-3" color="{{ $twoFactorEnabled ? 'green' : 'zinc' }}" size="sm">
|
||||
{{ $twoFactorEnabled ? __('Zusatzschutz aktiv') : __('Empfohlen') }}
|
||||
</flux:badge>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Letzter Login') }}</flux:text>
|
||||
<flux:text weight="bold" class="mt-1">
|
||||
{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Unbekannt') }}
|
||||
</flux:text>
|
||||
<flux:text class="mt-3 text-xs text-zinc-500">
|
||||
{{ $user->last_login_ip ?: __('Keine IP gespeichert') }}
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Aktive Sessions') }}</flux:text>
|
||||
<flux:text weight="bold" class="mt-1">{{ $sessions->count() }}</flux:text>
|
||||
<flux:text class="mt-3 text-xs text-zinc-500">
|
||||
{{ __('Aus den aktuellen Web-Sessions') }}
|
||||
</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-4">{{ __('Passwort ändern') }}</flux:heading>
|
||||
<form wire:submit="updatePassword" class="space-y-4">
|
||||
<flux:input wire:model="current_password" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
|
||||
<flux:input wire:model="password" type="password" :label="__('Neues Passwort')" autocomplete="new-password" required />
|
||||
<flux:input wire:model="password_confirmation" type="password" :label="__('Neues Passwort bestätigen')" autocomplete="new-password" required />
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary">{{ __('Passwort speichern') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-4">{{ __('E-Mail-Adresse ändern') }}</flux:heading>
|
||||
<form wire:submit="updateEmail" class="space-y-4">
|
||||
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
|
||||
<flux:text class="text-xs text-zinc-500">
|
||||
{{ __('Nach der Änderung kann eine erneute Bestätigung der E-Mail-Adresse erforderlich sein.') }}
|
||||
</flux:text>
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary">{{ __('E-Mail speichern') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-4">{{ __('Zwei-Faktor-Authentifizierung') }}</flux:heading>
|
||||
|
||||
@if(! $twoFactorEnabled)
|
||||
<flux:text class="text-sm text-zinc-500">
|
||||
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
|
||||
</flux:text>
|
||||
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
|
||||
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
||||
</flux:button>
|
||||
@else
|
||||
@if($twoFactorQrSvg)
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||
<div class="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
{!! $twoFactorQrSvg !!}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<flux:text class="text-sm">
|
||||
{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }}
|
||||
</flux:text>
|
||||
@if(! empty($recoveryCodes))
|
||||
<flux:heading size="xs">{{ __('Wiederherstellungs-Codes') }}</flux:heading>
|
||||
<ul class="grid grid-cols-2 gap-2 text-xs font-mono">
|
||||
@foreach($recoveryCodes as $code)
|
||||
<li class="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{{ $code }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<flux:button wire:click="regenerateRecoveryCodes" variant="ghost">
|
||||
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
|
||||
{{ __('Zwei-Faktor deaktivieren') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-0">
|
||||
<div class="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<flux:heading size="sm">{{ __('Aktive Sessions') }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Hier sehen Sie die letzten bekannten Web-Sessions Ihres Kontos. Abmelden erfolgt aktuell über das Nutzer-Menü.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse($sessions as $session)
|
||||
<div class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<flux:text weight="semibold">
|
||||
{{ $session->ip_address ?: __('IP unbekannt') }}
|
||||
</flux:text>
|
||||
<flux:text class="mt-1 truncate text-xs text-zinc-500">
|
||||
{{ Str::limit($session->user_agent ?: __('User-Agent unbekannt'), 120) }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<flux:badge color="zinc" size="sm">
|
||||
{{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.shield-check class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Keine Sessions gefunden') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Sobald Sessions protokolliert werden, erscheinen sie hier.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
212
resources/views/livewire/customer/tokens.blade.php
Normal file
212
resources/views/livewire/customer/tokens.blade.php
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Api\ApiAccessEligibilityService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Component
|
||||
{
|
||||
public string $tokenName = '';
|
||||
|
||||
/** @var list<string> */
|
||||
public array $selectedAbilities = ['press-releases:read'];
|
||||
|
||||
public ?string $plainTextToken = null;
|
||||
|
||||
public ?string $notification = null;
|
||||
|
||||
public ?string $eligibilityMessage = null;
|
||||
|
||||
private const ABILITIES = [
|
||||
'press-releases:read' => 'Pressemitteilungen lesen',
|
||||
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
|
||||
'press-release-images:write' => 'Bilder zu Pressemitteilungen verwalten',
|
||||
'companies:read' => 'Firmendaten lesen',
|
||||
'newsletter:subscribe' => 'Newsletter-Anmeldungen auslösen',
|
||||
];
|
||||
|
||||
public function createToken(): void
|
||||
{
|
||||
$eligibility = app(ApiAccessEligibilityService::class);
|
||||
$denialReason = $eligibility->denialReason(auth()->user());
|
||||
|
||||
if ($denialReason !== null) {
|
||||
$this->plainTextToken = null;
|
||||
$this->eligibilityMessage = __($denialReason);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $this->validate([
|
||||
'tokenName' => ['required', 'string', 'max:80'],
|
||||
'selectedAbilities' => ['required', 'array', 'min:1'],
|
||||
'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))],
|
||||
]);
|
||||
|
||||
$token = auth()->user()->createToken(
|
||||
$validated['tokenName'],
|
||||
$validated['selectedAbilities'],
|
||||
);
|
||||
|
||||
$this->plainTextToken = $token->plainTextToken;
|
||||
$this->notification = __('Token wurde erstellt. Bitte kopieren Sie ihn jetzt, er wird später nicht erneut angezeigt.');
|
||||
$this->eligibilityMessage = null;
|
||||
$this->tokenName = '';
|
||||
$this->selectedAbilities = ['press-releases:read'];
|
||||
}
|
||||
|
||||
public function revokeToken(int $tokenId): void
|
||||
{
|
||||
auth()->user()
|
||||
->tokens()
|
||||
->whereKey($tokenId)
|
||||
->delete();
|
||||
|
||||
$this->plainTextToken = null;
|
||||
$this->notification = __('Token wurde widerrufen.');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$eligibility = app(ApiAccessEligibilityService::class);
|
||||
$denialReason = $eligibility->denialReason(auth()->user());
|
||||
|
||||
return [
|
||||
'abilityOptions' => self::ABILITIES,
|
||||
'canCreateApiToken' => $denialReason === null,
|
||||
'apiTokenDenialReason' => $denialReason,
|
||||
'tokens' => auth()->user()
|
||||
->tokens()
|
||||
->latest()
|
||||
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('API-Tokens') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Erstellen und widerrufen Sie persönliche Tokens für die neue API v1.') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button href="{{ route('docs.api.v1') }}" variant="subtle">
|
||||
{{ __('API-Dokumentation') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
@if($notification)
|
||||
<flux:callout color="green" icon="check-circle">
|
||||
{{ $notification }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if($eligibilityMessage || $apiTokenDenialReason)
|
||||
<flux:callout color="yellow" icon="lock-closed">
|
||||
{{ $eligibilityMessage ?? $apiTokenDenialReason }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if($plainTextToken)
|
||||
<flux:callout color="yellow" icon="key">
|
||||
<div class="space-y-3">
|
||||
<flux:text weight="semibold">{{ __('Neuer Token') }}</flux:text>
|
||||
<code class="block overflow-x-auto rounded-md bg-zinc-950 px-3 py-2 text-sm text-white">{{ $plainTextToken }}</code>
|
||||
</div>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<form wire:submit="createToken">
|
||||
<flux:card class="space-y-5">
|
||||
<div>
|
||||
<flux:heading size="sm">{{ __('Neuen Token erstellen') }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Wählen Sie nur die Berechtigungen aus, die der jeweilige API-Client wirklich benötigt.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Name') }}</flux:label>
|
||||
<flux:input wire:model="tokenName" placeholder="{{ __('z.B. Website-Integration') }}" />
|
||||
<flux:error name="tokenName" />
|
||||
</flux:field>
|
||||
|
||||
<div>
|
||||
<flux:label>{{ __('Berechtigungen') }}</flux:label>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
@foreach($abilityOptions as $ability => $label)
|
||||
<flux:checkbox wire:model="selectedAbilities" value="{{ $ability }}" label="{{ $label }}" />
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:error name="selectedAbilities" class="mt-3" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary" icon="key" :disabled="! $canCreateApiToken">
|
||||
{{ __('Token erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</form>
|
||||
|
||||
<flux:card class="p-0">
|
||||
<div class="p-4">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Name') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Berechtigungen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Zuletzt genutzt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@forelse($tokens as $token)
|
||||
<flux:table.row wire:key="token-{{ $token->id }}">
|
||||
<flux:table.cell>
|
||||
<flux:text weight="semibold">{{ $token->name }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach($token->abilities ?? [] as $ability)
|
||||
<flux:badge size="sm" color="zinc">{{ $ability }}</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $token->created_at?->format('d.m.Y H:i') }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $token->last_used_at?->format('d.m.Y H:i') ?? __('Nie') }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
icon="trash"
|
||||
wire:click="revokeToken({{ $token->id }})"
|
||||
wire:confirm="{{ __('Diesen API-Token wirklich widerrufen?') }}"
|
||||
>
|
||||
{{ __('Widerrufen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<flux:icon.key class="size-10 text-zinc-300" />
|
||||
<flux:text weight="semibold" class="mt-3">{{ __('Keine API-Tokens vorhanden') }}</flux:text>
|
||||
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
|
||||
{{ __('Erstellen Sie erst dann einen Token, wenn eine konkrete API-Integration ihn benötigt.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
new #[Layout('components.layouts.app')] class extends Component
|
||||
{
|
||||
//
|
||||
}; ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ use App\Livewire\Actions\Logout;
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
new class extends Component
|
||||
{
|
||||
public string $password = '';
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +17,7 @@ new class extends Component {
|
|||
'password' => ['required', 'string', 'current_password'],
|
||||
]);
|
||||
|
||||
tap(Auth::user(), $logout(...))->delete();
|
||||
tap(Auth::user(), $logout(...))->forceDelete();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ use Illuminate\Support\Facades\Auth;
|
|||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
new #[Layout('components.layouts.app')] class extends Component
|
||||
{
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ use App\Models\User;
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
new #[Layout('components.layouts.app')] class extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
|
|
@ -35,7 +38,7 @@ new class extends Component {
|
|||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($user->id)
|
||||
Rule::unique(User::class)->ignore($user->id),
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,21 +53,21 @@ new class extends Component {
|
|||
x-transition:leave="transition ease-in duration-200 transform"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="-translate-x-full"
|
||||
class="fixed left-0 top-0 h-full w-80 sm:w-96 bg-white dark:bg-gray-900 shadow-2xl z-[70] overflow-y-auto transition-colors duration-200"
|
||||
class="fixed left-0 top-0 h-full w-80 sm:w-96 bg-white dark:bg-zinc-900 shadow-2xl z-[70] overflow-y-auto transition-colors duration-200"
|
||||
x-cloak
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between transition-colors duration-200">
|
||||
<div class="sticky top-0 bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 p-4 flex items-center justify-between transition-colors duration-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1 h-6 bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-secondary)] rounded-full"></span>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Navigation</h2>
|
||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Navigation</h2>
|
||||
</div>
|
||||
<button
|
||||
@click="$wire.toggleMenu()"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
class="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
aria-label="Menü schließen"
|
||||
>
|
||||
<svg class="h-6 w-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="h-6 w-6 text-zinc-600 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -77,15 +77,15 @@ new class extends Component {
|
|||
<nav class="p-6 space-y-6">
|
||||
<!-- Portal Section -->
|
||||
<div>
|
||||
<div class="py-2 text-sm font-semibold text-gray-900 dark:text-gray-100">Portal</div>
|
||||
<div class="py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">Portal</div>
|
||||
<div class="space-y-1">
|
||||
<a href="/" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Startseite
|
||||
</a>
|
||||
<a href="/kategorien" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/kategorien" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Kategorien
|
||||
</a>
|
||||
<a href="/suche" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/suche" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Suche
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -95,7 +95,7 @@ new class extends Component {
|
|||
<div>
|
||||
<button
|
||||
wire:click="toggleSection('services')"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -114,16 +114,16 @@ new class extends Component {
|
|||
</button>
|
||||
@if($this->isSectionOpen('services'))
|
||||
<div class="mt-2 space-y-1 pl-6">
|
||||
<a href="/veroeffentlichen" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/veroeffentlichen" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Pressemitteilung veröffentlichen
|
||||
</a>
|
||||
<a href="/newsrooms" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/newsrooms" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Newsrooms
|
||||
</a>
|
||||
<a href="/preise" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/preise" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Preise & Leistungen
|
||||
</a>
|
||||
<a href="/api" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/api" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
API & Integrationen
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -134,7 +134,7 @@ new class extends Component {
|
|||
<div>
|
||||
<button
|
||||
wire:click="toggleSection('about')"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -153,19 +153,19 @@ new class extends Component {
|
|||
</button>
|
||||
@if($this->isSectionOpen('about'))
|
||||
<div class="mt-2 space-y-1 pl-6">
|
||||
<a href="/ueber-uns" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/ueber-uns" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Über Business Portal
|
||||
</a>
|
||||
<a href="/team" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/team" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Team
|
||||
</a>
|
||||
<a href="/partner" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/partner" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Partner
|
||||
</a>
|
||||
<a href="/karriere" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/karriere" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Karriere
|
||||
</a>
|
||||
<a href="/presse" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/presse" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Presse
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -176,7 +176,7 @@ new class extends Component {
|
|||
<div>
|
||||
<button
|
||||
wire:click="toggleSection('support')"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -195,13 +195,13 @@ new class extends Component {
|
|||
</button>
|
||||
@if($this->isSectionOpen('support'))
|
||||
<div class="mt-2 space-y-1 pl-6">
|
||||
<a href="/faq" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/faq" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
FAQ
|
||||
</a>
|
||||
<a href="/hilfe" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/hilfe" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Hilfe-Center
|
||||
</a>
|
||||
<a href="/kontakt" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/kontakt" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Kontakt
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -212,7 +212,7 @@ new class extends Component {
|
|||
<div>
|
||||
<button
|
||||
wire:click="toggleSection('legal')"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
class="flex items-center justify-between w-full py-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -231,16 +231,16 @@ new class extends Component {
|
|||
</button>
|
||||
@if($this->isSectionOpen('legal'))
|
||||
<div class="mt-2 space-y-1 pl-6">
|
||||
<a href="/impressum" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/impressum" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Impressum
|
||||
</a>
|
||||
<a href="/datenschutz" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/datenschutz" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Datenschutz
|
||||
</a>
|
||||
<a href="/agb" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/agb" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
AGB
|
||||
</a>
|
||||
<a href="/cookies" class="block py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:translate-x-1 transition-all">
|
||||
<a href="/cookies" class="block py-2 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:translate-x-1 transition-all">
|
||||
Cookie-Richtlinien
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -249,7 +249,7 @@ new class extends Component {
|
|||
</nav>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="sticky bottom-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 p-6 space-y-3 transition-colors duration-200">
|
||||
<div class="sticky bottom-0 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 p-6 space-y-3 transition-colors duration-200">
|
||||
<a
|
||||
href="/veroeffentlichen"
|
||||
class="block w-full text-center px-4 py-3 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all"
|
||||
|
|
@ -259,13 +259,13 @@ new class extends Component {
|
|||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/login"
|
||||
class="flex-1 text-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-all"
|
||||
class="flex-1 text-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-all"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
class="flex-1 text-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-all"
|
||||
class="flex-1 text-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-all"
|
||||
>
|
||||
Registrieren
|
||||
</a>
|
||||
|
|
@ -274,14 +274,14 @@ new class extends Component {
|
|||
|
||||
<!-- Contact Info -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg transition-colors duration-200">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg transition-colors duration-200">
|
||||
<div class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400 mb-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>Kontakt</span>
|
||||
</div>
|
||||
<a href="mailto:info@businessportal.de" class="text-sm text-gray-900 dark:text-gray-100 hover:text-[var(--color-primary)] transition-colors">
|
||||
<a href="mailto:info@businessportal.de" class="text-sm text-zinc-900 dark:text-zinc-100 hover:text-[var(--color-primary)] transition-colors">
|
||||
info@businessportal.de
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,140 +60,167 @@ new class extends Component {
|
|||
|
||||
private function getTimeframeLabel()
|
||||
{
|
||||
return match($this->timeframe) {
|
||||
return match ($this->timeframe) {
|
||||
'today' => 'Heute',
|
||||
'7' => '7 Tage',
|
||||
'30' => '30 Tage',
|
||||
default => 'Zeitraum'
|
||||
default => 'Zeitraum',
|
||||
};
|
||||
}
|
||||
|
||||
private function getIndustryLabel()
|
||||
{
|
||||
return match($this->industry) {
|
||||
'it' => 'IT & Software',
|
||||
'finance' => 'Finanzen',
|
||||
'health' => 'Gesundheit',
|
||||
'auto' => 'Automobil',
|
||||
'energy' => 'Energie',
|
||||
default => 'Alle Branchen'
|
||||
return match ($this->industry) {
|
||||
'it-digital' => 'IT & Digitalisierung',
|
||||
'industry-tech' => 'Industrie & Technik',
|
||||
'finance-insurance' => 'Finanzen & Versicherungen',
|
||||
'retail-ecommerce' => 'Handel & E-Commerce',
|
||||
'construction-realestate' => 'Bauen & Immobilien',
|
||||
'mobility-logistics' => 'Mobilität & Logistik',
|
||||
'energy-environment' => 'Energie & Umwelt',
|
||||
'medicine-health' => 'Medizin & Gesundheit',
|
||||
'hr-personnel' => 'Personal & HR',
|
||||
'marketing-pr-media' => 'Marketing, PR & Medien',
|
||||
'law-tax' => 'Recht & Steuern',
|
||||
'politics-ngo' => 'Politik, Verbände & NGOs',
|
||||
'science-research' => 'Wissenschaft & Forschung',
|
||||
'lifestyle' => 'Lifestyle',
|
||||
'tourism-culture' => 'Tourismus & Kultur',
|
||||
default => 'Alle Branchen',
|
||||
};
|
||||
}
|
||||
|
||||
private function getRegionLabel()
|
||||
{
|
||||
return match($this->region) {
|
||||
return match ($this->region) {
|
||||
'de' => 'Deutschland',
|
||||
'at' => 'Österreich',
|
||||
'ch' => 'Schweiz',
|
||||
default => 'Alle Regionen'
|
||||
default => 'Alle Regionen',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="sticky top-16 z-40 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-b border-gray-200 dark:border-gray-800 shadow-sm transition-colors duration-200">
|
||||
<div
|
||||
class="sticky top-16 z-40 bg-white/95 dark:bg-zinc-800 backdrop-blur-sm border-b border-zinc-200 dark:border-zinc-800 shadow-sm transition-colors duration-200">
|
||||
<div class="container mx-auto px-4 py-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Zeitraum -->
|
||||
<div class="relative">
|
||||
<select
|
||||
wire:model.live="timeframe"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
|
||||
>
|
||||
<select wire:model.live="timeframe"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
|
||||
<option value="today">Heute</option>
|
||||
<option value="7">7 Tage</option>
|
||||
<option value="30">30 Tage</option>
|
||||
<option value="custom">Zeitraum</option>
|
||||
</select>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Branche -->
|
||||
<div class="relative">
|
||||
<select
|
||||
wire:model.live="industry"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
|
||||
>
|
||||
<select wire:model.live="industry"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
|
||||
<option value="all">Alle Branchen</option>
|
||||
<option value="it">IT & Software</option>
|
||||
<option value="finance">Finanzen</option>
|
||||
<option value="health">Gesundheit</option>
|
||||
<option value="auto">Automobil</option>
|
||||
<option value="energy">Energie</option>
|
||||
<option value="it-digital">IT & Digitalisierung</option>
|
||||
<option value="industry-tech">Industrie & Technik</option>
|
||||
<option value="finance-insurance">Finanzen & Versicherungen</option>
|
||||
<option value="retail-ecommerce">Handel & E-Commerce</option>
|
||||
<option value="construction-realestate">Bauen & Immobilien</option>
|
||||
<option value="mobility-logistics">Mobilität & Logistik</option>
|
||||
<option value="energy-environment">Energie & Umwelt</option>
|
||||
<option value="medicine-health">Medizin & Gesundheit</option>
|
||||
<option value="hr-personnel">Personal & HR</option>
|
||||
<option value="marketing-pr-media">Marketing, PR & Medien</option>
|
||||
<option value="law-tax">Recht & Steuern</option>
|
||||
<option value="politics-ngo">Politik, Verbände & NGOs</option>
|
||||
<option value="science-research">Wissenschaft & Forschung</option>
|
||||
<option value="lifestyle">Lifestyle</option>
|
||||
<option value="tourism-culture">Tourismus & Kultur</option>
|
||||
</select>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Region -->
|
||||
<div class="relative">
|
||||
<select
|
||||
wire:model.live="region"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
|
||||
>
|
||||
<select wire:model.live="region"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
|
||||
<option value="all">Alle Regionen</option>
|
||||
<option value="de">Deutschland</option>
|
||||
<option value="at">Österreich</option>
|
||||
<option value="ch">Schweiz</option>
|
||||
</select>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z">
|
||||
</path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Sortierung -->
|
||||
<div class="relative">
|
||||
<select
|
||||
wire:model.live="sortBy"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-gray-400 dark:hover:border-gray-600 cursor-pointer"
|
||||
>
|
||||
<select wire:model.live="sortBy"
|
||||
class="appearance-none pl-10 pr-8 py-2 text-sm border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all hover:border-zinc-400 dark:hover:border-zinc-600 cursor-pointer">
|
||||
<option value="newest">Neueste</option>
|
||||
<option value="relevance">Relevanz</option>
|
||||
</select>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
|
||||
</svg>
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500 dark:text-zinc-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
@if(count($activeFilters) > 0)
|
||||
@if (count($activeFilters) > 0)
|
||||
<div class="flex items-center gap-2 flex-wrap ml-auto">
|
||||
@foreach($activeFilters as $filter)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 text-[var(--color-primary)] border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 transition-all hover:bg-[var(--color-primary)]/20 dark:hover:bg-[var(--color-primary)]/30">
|
||||
@foreach ($activeFilters as $filter)
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium bg-[var(--color-primary)]/10 dark:bg-[var(--color-primary)]/20 text-[var(--color-primary)] border border-[var(--color-primary)]/20 dark:border-[var(--color-primary)]/30 transition-all hover:bg-[var(--color-primary)]/20 dark:hover:bg-[var(--color-primary)]/30">
|
||||
{{ $filter['label'] }}
|
||||
<button
|
||||
wire:click="removeFilter('{{ $filter['key'] }}')"
|
||||
class="hover:text-[var(--color-primary)]/70 transition-colors"
|
||||
>
|
||||
<button wire:click="removeFilter('{{ $filter['key'] }}')"
|
||||
class="hover:text-[var(--color-primary)]/70 transition-colors">
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
@endforeach
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button
|
||||
wire:click="resetFilters"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<button wire:click="resetFilters"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
||||
Alle zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,54 +18,77 @@ new class extends Component {
|
|||
}
|
||||
}; ?>
|
||||
|
||||
<div x-data="{ darkMode: $wire.entangle('darkMode') }" x-init="
|
||||
darkMode = localStorage.getItem('theme') === 'dark';
|
||||
$watch('darkMode', value => {
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light');
|
||||
if (value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
// Initialize theme on load
|
||||
if (darkMode) {
|
||||
<div x-data="{ darkMode: $wire.entangle('darkMode') }" x-init="darkMode = localStorage.getItem('theme') === 'dark';
|
||||
$watch('darkMode', value => {
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light');
|
||||
if (value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
">
|
||||
<footer class="bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 py-8 mt-auto transition-colors duration-200">
|
||||
});
|
||||
// Initialize theme on load
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}">
|
||||
<footer
|
||||
class="bg-zinc-100 glow-soft dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 py-8 mt-auto transition-colors duration-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Unternehmen</h3>
|
||||
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Unternehmen</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/ueber-uns" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Über uns</a></li>
|
||||
<li><a href="/kontakt" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Kontakt</a></li>
|
||||
<li><a href="/presse" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Presse</a></li>
|
||||
<li><a href="/ueber-uns"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Über
|
||||
uns</a></li>
|
||||
<li><a href="/kontakt"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Kontakt</a>
|
||||
</li>
|
||||
<li><a href="/presse"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Presse</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Services</h3>
|
||||
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Services</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/preise" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Preise</a></li>
|
||||
<li><a href="/newsrooms" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Newsrooms</a></li>
|
||||
<li><a href="/api" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">API</a></li>
|
||||
<li><a href="/preise"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Preise</a>
|
||||
</li>
|
||||
<li><a href="/newsrooms"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Newsrooms</a>
|
||||
</li>
|
||||
<li><a href="/api"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">API</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Rechtliches</h3>
|
||||
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/impressum" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Impressum</a></li>
|
||||
<li><a href="/datenschutz" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Datenschutz</a></li>
|
||||
<li><a href="/agb" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">AGB</a></li>
|
||||
<li><a href="/impressum"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Impressum</a>
|
||||
</li>
|
||||
<li><a href="/datenschutz"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Datenschutz</a>
|
||||
</li>
|
||||
<li><a href="/agb"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">AGB</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Folgen Sie uns</h3>
|
||||
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100 mb-3">Folgen Sie uns</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="https://linkedin.com" target="_blank" rel="noopener" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">LinkedIn</a></li>
|
||||
<li><a href="https://twitter.com" target="_blank" rel="noopener" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Twitter</a></li>
|
||||
<li><a href="/rss" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">RSS</a></li>
|
||||
<li><a href="https://linkedin.com" target="_blank" rel="noopener"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">LinkedIn</a>
|
||||
</li>
|
||||
<li><a href="https://twitter.com" target="_blank" rel="noopener"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">Twitter</a>
|
||||
</li>
|
||||
<li><a href="/rss"
|
||||
class="text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors">RSS</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,50 +96,84 @@ new class extends Component {
|
|||
@php
|
||||
$theme = config('app.theme', 'businessportal24');
|
||||
$isPresseecho = $theme === 'presseecho';
|
||||
$isBusinessportal24 = $theme === 'businessportal24';
|
||||
$currentYear = date('Y');
|
||||
$siteName = $isPresseecho ? 'Presseecho' : 'Business Portal';
|
||||
@endphp
|
||||
|
||||
<!-- Cross-Link für Presseecho -->
|
||||
@if($isPresseecho)
|
||||
<div class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
|
||||
@if ($isPresseecho)
|
||||
<div
|
||||
class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
|
||||
Für maximale Reichweite?
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Besuchen Sie unser reichweitenstarkes Portal <strong class="text-gray-900 dark:text-gray-100">Businessportal24</strong>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Besuchen Sie unser reichweitenstarkes Portal <strong
|
||||
class="text-zinc-900 dark:text-zinc-100">Businessportal24</strong>
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://businessportal24.test"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all whitespace-nowrap">
|
||||
Zu Businessportal24
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
@if ($isBusinessportal24)
|
||||
<div
|
||||
class="mb-6 p-5 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5 dark:from-[var(--color-primary)]/10 dark:to-[var(--color-secondary)]/10 rounded-lg border border-[var(--color-primary)]/10">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-1">
|
||||
Für maximale Reichweite?
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Besuchen Sie unser reichweitenstarkes Portal <strong
|
||||
class="text-zinc-900 dark:text-zinc-100">presseecho</strong>
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://presseecho.test"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] hover:from-[var(--color-primary)]/90 hover:to-[var(--color-secondary)]/90 rounded-lg shadow-md hover:shadow-lg transition-all whitespace-nowrap">
|
||||
Zu Presseecho
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pt-6 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">© {{ $currentYear }} {{ $siteName }}. Alle Rechte vorbehalten.</p>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">© {{ $currentYear }} {{ $siteName }}. Alle
|
||||
Rechte vorbehalten.</p>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="darkMode = !darkMode"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-all"
|
||||
aria-label="Theme wechseln"
|
||||
>
|
||||
<button @click="darkMode = !darkMode"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-lg transition-all"
|
||||
aria-label="Theme wechseln">
|
||||
<!-- Sun Icon (zeigt in Dark Mode) -->
|
||||
<svg x-show="darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-cloak>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
<svg x-show="darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
x-cloak>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<!-- Moon Icon (zeigt in Light Mode) -->
|
||||
<svg x-show="!darkMode" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
<svg x-show="!darkMode" class="h-5 w-5" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z">
|
||||
</path>
|
||||
</svg>
|
||||
<span x-text="darkMode ? 'Hell' : 'Dunkel'"></span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ new class extends Component {
|
|||
|
||||
<div>
|
||||
<header
|
||||
class="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200/50 dark:border-gray-800/50 shadow-sm backdrop-blur-sm transition-colors duration-200">
|
||||
class="sticky top-0 z-50 bg-white dark:bg-zinc-900 border-b border-zinc-200/50 dark:border-zinc-800/50 shadow-sm backdrop-blur-sm transition-colors duration-200">
|
||||
<!-- Brand Accent Bar -->
|
||||
<div
|
||||
class="h-1 bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-secondary)] to-[var(--color-primary)]">
|
||||
|
|
@ -26,9 +26,9 @@ new class extends Component {
|
|||
<div class="flex items-center gap-3">
|
||||
<!-- Burger Menu Button (immer sichtbar) -->
|
||||
<button @click="$dispatch('toggle-mobile-menu')"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
class="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-lg transition-colors"
|
||||
aria-label="Menü öffnen">
|
||||
<svg class="h-6 w-6 text-gray-900 dark:text-gray-100" fill="none" stroke="currentColor"
|
||||
<svg class="h-6 w-6 text-zinc-900 dark:text-zinc-100" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
|
|
@ -42,7 +42,7 @@ new class extends Component {
|
|||
// Versuche verschiedene Logo-Namenskonventionen
|
||||
$logoVariants = [
|
||||
"images/{$theme}-logo.svg",
|
||||
"images/" . str_replace('24', '', $theme) . "-logo.svg", // businessportal-logo.svg
|
||||
'images/' . str_replace('24', '', $theme) . '-logo.svg', // businessportal-logo.svg
|
||||
];
|
||||
|
||||
$logoPath = null;
|
||||
|
|
@ -71,20 +71,20 @@ new class extends Component {
|
|||
<!-- Search - Desktop -->
|
||||
<div class="hidden md:flex flex-1 max-w-xl">
|
||||
<form wire:submit.prevent="search" class="relative w-full">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" fill="none"
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input type="search" wire:model="searchQuery" placeholder="Pressemitteilungen durchsuchen..."
|
||||
class="pl-10 w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
|
||||
class="pl-10 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Search Icon - Mobile -->
|
||||
<button @click="$wire.showMobileSearch = !$wire.showMobileSearch"
|
||||
class="md:hidden p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||
<svg class="h-5 w-5 text-gray-900 dark:text-gray-100" fill="none" stroke="currentColor"
|
||||
class="md:hidden p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
|
||||
<svg class="h-5 w-5 text-zinc-900 dark:text-zinc-100" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
|
|
@ -101,17 +101,17 @@ new class extends Component {
|
|||
@if ($isPresseecho)
|
||||
<!-- Presseecho: Dezente Navigation -->
|
||||
<a href="/login"
|
||||
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href="/beitrag-einreichen"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors border border-gray-300 dark:border-gray-700">
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors border border-zinc-300 dark:border-zinc-700">
|
||||
Beitrag einreichen
|
||||
</a>
|
||||
@else
|
||||
<!-- Businessportal24: Prominenter CTA -->
|
||||
<a href="/login"
|
||||
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||
class="hidden sm:inline-flex items-center px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href="/veroeffentlichen"
|
||||
|
|
@ -127,15 +127,15 @@ new class extends Component {
|
|||
x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="md:hidden border-t border-gray-200 dark:border-gray-800 px-4 py-3 bg-white dark:bg-gray-900" x-cloak>
|
||||
class="md:hidden border-t border-zinc-200 dark:border-zinc-800 px-4 py-3 bg-white dark:bg-zinc-900" x-cloak>
|
||||
<form wire:submit.prevent="search" class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400 dark:text-zinc-500"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input type="search" wire:model="searchQuery" placeholder="Pressemitteilungen durchsuchen..."
|
||||
class="pl-10 w-full rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
|
||||
class="pl-10 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all" />
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
159
resources/views/livewire/web/press-release-feed.blade.php
Normal file
159
resources/views/livewire/web/press-release-feed.blade.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new class extends Component {
|
||||
use WithPagination;
|
||||
|
||||
public string $timeframe = 'all';
|
||||
|
||||
#[Locked]
|
||||
public ?string $portal = null;
|
||||
|
||||
protected array $queryString = [
|
||||
'timeframe' => ['except' => 'all'],
|
||||
];
|
||||
|
||||
public function setTimeframe(string $timeframe): void
|
||||
{
|
||||
$this->timeframe = $timeframe;
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'releases' => $this->pressReleases(),
|
||||
'totalCount' => $this->totalCount(),
|
||||
];
|
||||
}
|
||||
|
||||
private function pressReleases(): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery()
|
||||
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereDate('published_at', today()))
|
||||
->when($this->timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', now()->subDays(7)))
|
||||
->orderByDesc('published_at')
|
||||
->paginate(8);
|
||||
}
|
||||
|
||||
private function totalCount(): int
|
||||
{
|
||||
return $this->baseQuery()->count();
|
||||
}
|
||||
|
||||
private function baseQuery(): Builder
|
||||
{
|
||||
return PressRelease::query()
|
||||
->with([
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', 'de'),
|
||||
'images' => fn ($query) => $query
|
||||
->orderByDesc('is_preview')
|
||||
->orderBy('sort_order')
|
||||
->limit(1),
|
||||
])
|
||||
->whereIn('portal', $this->portalValues())
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->where('language', 'de')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function portalValues(): array
|
||||
{
|
||||
$primary = $this->portal ?? Portal::Businessportal24->value;
|
||||
|
||||
return [$primary, Portal::Both->value];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div wire:loading.class="opacity-60">
|
||||
<header class="flex items-baseline justify-between mb-4 min-h-[34px] flex-wrap gap-3">
|
||||
<h2 class="font-serif text-[28px] font-semibold m-0 tracking-[-0.3px] leading-[1.2] text-ink">
|
||||
Aktuelle Meldungen
|
||||
</h2>
|
||||
<div class="flex gap-[18px] text-[12.5px] text-ink-3">
|
||||
<button type="button" wire:click="setTimeframe('all')"
|
||||
class="cursor-pointer transition-colors @if ($timeframe === 'all') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
|
||||
Alle
|
||||
</button>
|
||||
<button type="button" wire:click="setTimeframe('today')"
|
||||
class="cursor-pointer transition-colors @if ($timeframe === 'today') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
|
||||
Heute
|
||||
</button>
|
||||
<button type="button" wire:click="setTimeframe('week')"
|
||||
class="cursor-pointer transition-colors @if ($timeframe === 'week') text-ink border-b-[1.5px] border-brand pb-0.5 @else hover:text-ink @endif">
|
||||
Diese Woche
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<hr class="rule-strong">
|
||||
|
||||
@php
|
||||
$items = $releases->items();
|
||||
$top = $items[0] ?? null;
|
||||
$rest = array_slice($items, 1);
|
||||
|
||||
$mockFeedItems = [
|
||||
['time' => '13:42', 'date' => '12. Mai', 'category' => 'Tourismus', 'title' => 'Nachhaltiger Tourismus auf Erfolgskurs: Buchungen steigen um 45 %', 'company' => 'GreenTravel Consulting', 'city' => 'Berlin', 'recommended' => true],
|
||||
['time' => '12:55', 'date' => '12. Mai', 'category' => 'Bildung', 'title' => 'Digitalisierung im Bildungssektor: Schulen erhalten 2 Mrd. Euro Förderung', 'company' => 'EduTech Initiative', 'city' => 'Frankfurt'],
|
||||
['time' => '11:20', 'date' => '12. Mai', 'category' => 'Medien', 'title' => 'Medienbranche im Umbruch: Streaming-Dienste überholen klassisches TV', 'company' => 'MediaWatch Analytics', 'city' => 'Hamburg'],
|
||||
['time' => '10:48', 'date' => '12. Mai', 'category' => 'Handel', 'title' => 'Einzelhandel setzt auf KI: Personalisierte Shopping-Erlebnisse werden Standard', 'company' => 'RetailTech Innovations', 'city' => 'Köln', 'recommended' => true],
|
||||
['time' => '09:33', 'date' => '12. Mai', 'category' => 'Gesundheit', 'title' => 'Telemedizin-Boom: 3 Millionen Online-Sprechstunden im letzten Quartal', 'company' => 'HealthConnect Digital', 'city' => 'Stuttgart'],
|
||||
['time' => '08:15', 'date' => '12. Mai', 'category' => 'Mobilität', 'title' => 'E-Mobilität: Ladeinfrastruktur wächst um 38 % gegenüber Vorjahr', 'company' => 'eMobility Verband', 'city' => 'Düsseldorf'],
|
||||
];
|
||||
|
||||
$inFeedAd = [
|
||||
'time' => '12:14',
|
||||
'date' => now()->translatedFormat('j. MMM'),
|
||||
'category' => 'Cloud · Software',
|
||||
'title' => 'Microsoft Azure: Neue EU-Region Frankfurt mit DSGVO-zertifizierter KI-Infrastruktur',
|
||||
'company' => 'Microsoft Deutschland GmbH',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@if ($top)
|
||||
<x-web.feed-top-item :release="$top" />
|
||||
@else
|
||||
<x-web.feed-top-item />
|
||||
@endif
|
||||
|
||||
@if (! empty($rest))
|
||||
@foreach ($rest as $i => $release)
|
||||
<x-web.feed-item :release="$release" :recommended="in_array($i, [0, 3], true)" />
|
||||
@endforeach
|
||||
@else
|
||||
@foreach ($mockFeedItems as $mock)
|
||||
<x-web.feed-item :mock="$mock" />
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<x-web.feed-ad :ad="$inFeedAd" />
|
||||
|
||||
@if ($releases->hasMorePages())
|
||||
<div class="flex justify-center mt-7">
|
||||
<a href="{{ $releases->nextPageUrl() }}"
|
||||
class="inline-flex items-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold text-ink bg-transparent border border-bg-rule-strong rounded-[2px] cursor-pointer hover:bg-ink hover:text-bg transition-colors">
|
||||
Weitere {{ number_format(max(0, $totalCount - $releases->currentPage() * $releases->perPage()), 0, ',', '.') }} Meldungen anzeigen →
|
||||
</a>
|
||||
</div>
|
||||
@elseif (! $top)
|
||||
<div class="flex justify-center mt-7">
|
||||
<a href="{{ route('kategorien') }}"
|
||||
class="inline-flex items-center gap-2 px-[18px] py-2.5 text-[13px] font-semibold text-ink bg-transparent border border-bg-rule-strong rounded-[2px] cursor-pointer hover:bg-ink hover:text-bg transition-colors">
|
||||
Alle Rubriken entdecken →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -13,7 +13,7 @@ new class extends Component {
|
|||
'title' => 'KI-Revolution: Deutsche Unternehmen investieren Milliarden in Automatisierung',
|
||||
'teaser' => 'Eine neue Studie zeigt: Unternehmen im DACH-Raum planen für 2025 Investitionen in Höhe von über 15 Milliarden Euro in KI-gestützte Automatisierungslösungen.',
|
||||
'company' => 'TechVision Analytics',
|
||||
'industry' => 'IT & Software',
|
||||
'industry' => 'IT & Digitalisierung',
|
||||
'region' => 'Deutschland',
|
||||
'date' => '17. Okt 2024',
|
||||
'contentType' => 'ANALYSE',
|
||||
|
|
@ -27,7 +27,7 @@ new class extends Component {
|
|||
'title' => 'Energiewende beschleunigt sich: Neue Rekorde bei erneuerbaren Energien',
|
||||
'teaser' => 'Im ersten Quartal 2025 erreicht der Anteil erneuerbarer Energien am Strommix einen historischen Höchststand von 58%.',
|
||||
'company' => 'GreenPower Deutschland',
|
||||
'industry' => 'Energie',
|
||||
'industry' => 'Energie & Umwelt',
|
||||
'region' => 'Deutschland',
|
||||
'date' => '16. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
|
|
@ -39,7 +39,7 @@ new class extends Component {
|
|||
'title' => 'FinTech-Startup sichert sich 45 Millionen Euro in Series-B-Runde',
|
||||
'teaser' => 'Das Berliner FinTech-Startup PaymentFlow konnte in einer Series-B-Finanzierungsrunde 45 Millionen Euro einsammeln.',
|
||||
'company' => 'PaymentFlow GmbH',
|
||||
'industry' => 'Finanzen',
|
||||
'industry' => 'Finanzen & Versicherungen',
|
||||
'region' => 'Berlin',
|
||||
'date' => '15. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
|
|
@ -51,7 +51,7 @@ new class extends Component {
|
|||
'title' => 'Gesundheitsbranche setzt verstärkt auf digitale Lösungen',
|
||||
'teaser' => 'Telemedizin und KI-gestützte Diagnostik werden zum Standard: 78% der Krankenhäuser in Deutschland planen Investitionen.',
|
||||
'company' => 'MediTech Solutions',
|
||||
'industry' => 'Gesundheit',
|
||||
'industry' => 'Medizin & Gesundheit',
|
||||
'region' => 'München',
|
||||
'date' => '14. Okt 2024',
|
||||
'contentType' => 'INTERVIEW',
|
||||
|
|
@ -63,7 +63,7 @@ new class extends Component {
|
|||
'title' => 'Automobilindustrie: Transformation zur E-Mobilität nimmt Fahrt auf',
|
||||
'teaser' => 'Führende Automobilhersteller kündigen massive Investitionen in E-Mobilität an. Bis 2030 sollen 80% der Neufahrzeuge elektrisch sein.',
|
||||
'company' => 'Auto Industry Report',
|
||||
'industry' => 'Automobil',
|
||||
'industry' => 'Mobilität & Logistik',
|
||||
'region' => 'Stuttgart',
|
||||
'date' => '13. Okt 2024',
|
||||
'contentType' => 'ANALYSE',
|
||||
|
|
@ -76,14 +76,90 @@ new class extends Component {
|
|||
'title' => 'Cybersecurity: Unternehmen verstärken Schutzmaßnahmen gegen Hackerangriffe',
|
||||
'teaser' => 'Nach einer Serie von Cyberattacken erhöhen deutsche Unternehmen ihre Investitionen in IT-Sicherheit um durchschnittlich 35%.',
|
||||
'company' => 'CyberSafe Europe',
|
||||
'industry' => 'IT & Software',
|
||||
'industry' => 'IT & Digitalisierung',
|
||||
'region' => 'Frankfurt',
|
||||
'date' => '12. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
'hasImage' => true,
|
||||
'imageUrl' => 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=800&h=600&fit=crop',
|
||||
],
|
||||
]
|
||||
// Neue Artikel ohne Bild für verschiedene Branchen
|
||||
[
|
||||
'slug' => 'immobilienmarkt-trendwende',
|
||||
'title' => 'Immobilienmarkt 2025: Experten prognostizieren Trendwende bei Kaufpreisen',
|
||||
'teaser' => 'Nach Jahren steigender Preise zeigen aktuelle Analysen eine Stabilisierung des Immobilienmarktes. Besonders Metropolregionen verzeichnen erstmals rückläufige Tendenzen.',
|
||||
'company' => 'ImmoConsult Deutschland',
|
||||
'industry' => 'Bauen & Immobilien',
|
||||
'region' => 'Hamburg',
|
||||
'date' => '11. Okt 2024',
|
||||
'contentType' => 'ANALYSE',
|
||||
'hasImage' => false,
|
||||
'hasPdf' => true,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
[
|
||||
'slug' => 'tourismus-nachhaltigkeit',
|
||||
'title' => 'Nachhaltiger Tourismus auf Erfolgskurs: Buchungen steigen um 45%',
|
||||
'teaser' => 'Öko-Hotels und klimaneutrales Reisen liegen im Trend. Die Reisebranche verzeichnet einen deutlichen Wandel im Buchungsverhalten deutscher Urlauber.',
|
||||
'company' => 'GreenTravel Consulting',
|
||||
'industry' => 'Tourismus & Kultur',
|
||||
'region' => 'Österreich',
|
||||
'date' => '10. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
'hasImage' => false,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
[
|
||||
'slug' => 'bildungssektor-digitalisierung',
|
||||
'title' => 'Digitalisierung im Bildungssektor: Schulen erhalten 2 Milliarden Euro Förderung',
|
||||
'teaser' => 'Bundesweites Digitalpakt-Programm wird ausgeweitet. Schwerpunkt liegt auf KI-gestützten Lernplattformen und moderner IT-Infrastruktur für Schulen.',
|
||||
'company' => 'EduTech Initiative',
|
||||
'industry' => 'Wissenschaft & Forschung',
|
||||
'region' => 'Berlin',
|
||||
'date' => '09. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
'hasImage' => false,
|
||||
'hasPdf' => true,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
[
|
||||
'slug' => 'medienbranche-streaming',
|
||||
'title' => 'Medienbranche im Umbruch: Streaming-Dienste überholen klassisches TV',
|
||||
'teaser' => 'Erstmals nutzen mehr Deutsche regelmäßig Streaming-Plattformen als lineares Fernsehen. Die Werbebudgets verschieben sich entsprechend.',
|
||||
'company' => 'MediaWatch Analytics',
|
||||
'industry' => 'Marketing, PR & Medien',
|
||||
'region' => 'Köln',
|
||||
'date' => '08. Okt 2024',
|
||||
'contentType' => 'ANALYSE',
|
||||
'hasImage' => false,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
[
|
||||
'slug' => 'einzelhandel-ki-revolution',
|
||||
'title' => 'Einzelhandel setzt auf KI: Personalisierte Shopping-Erlebnisse werden Standard',
|
||||
'teaser' => 'Künstliche Intelligenz revolutioniert den Handel. Von virtuellen Anproben bis zu KI-gestützter Beratung – der Einzelhandel wird digital.',
|
||||
'company' => 'RetailTech Innovations',
|
||||
'industry' => 'Handel & E-Commerce',
|
||||
'region' => 'Düsseldorf',
|
||||
'date' => '07. Okt 2024',
|
||||
'contentType' => 'INTERVIEW',
|
||||
'hasImage' => false,
|
||||
'hasPdf' => true,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
[
|
||||
'slug' => 'gesundheit-telemedizin-boom',
|
||||
'title' => 'Telemedizin-Boom: 3 Millionen Online-Sprechstunden im letzten Quartal',
|
||||
'teaser' => 'Die Akzeptanz von Videosprechstunden erreicht neue Höchststände. Besonders ländliche Regionen profitieren von der digitalen Gesundheitsversorgung.',
|
||||
'company' => 'HealthConnect Digital',
|
||||
'industry' => 'Medizin & Gesundheit',
|
||||
'region' => 'Schweiz',
|
||||
'date' => '06. Okt 2024',
|
||||
'contentType' => 'FACHMELDUNG',
|
||||
'hasImage' => false,
|
||||
'imageUrl' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
|
@ -95,35 +171,16 @@ new class extends Component {
|
|||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@foreach($releases as $release)
|
||||
@if($isPresseecho)
|
||||
@foreach ($releases as $release)
|
||||
@if ($isPresseecho)
|
||||
{{-- Presseecho: Hochwertige Magazine-Card --}}
|
||||
<x-web.presseecho-release-card
|
||||
:title="$release['title']"
|
||||
:teaser="$release['teaser']"
|
||||
:company="$release['company']"
|
||||
:industry="$release['industry']"
|
||||
:region="$release['region']"
|
||||
:date="$release['date']"
|
||||
:contentType="$release['contentType'] ?? 'FACHMELDUNG'"
|
||||
:slug="$release['slug']"
|
||||
:imageUrl="$release['imageUrl'] ?? null"
|
||||
/>
|
||||
<x-web.press-release-card :title="$release['title']" :teaser="$release['teaser']" :company="$release['company']" :industry="$release['industry']"
|
||||
:region="$release['region']" :date="$release['date']" :contentType="$release['contentType'] ?? 'FACHMELDUNG'" :slug="$release['slug']" :imageUrl="$release['imageUrl'] ?? null" />
|
||||
@else
|
||||
{{-- Businessportal24: Standard-Card --}}
|
||||
<x-web.press-release-card
|
||||
:title="$release['title']"
|
||||
:teaser="$release['teaser']"
|
||||
:company="$release['company']"
|
||||
:industry="$release['industry']"
|
||||
:region="$release['region']"
|
||||
:date="$release['date']"
|
||||
:hasImage="$release['hasImage'] ?? false"
|
||||
:hasPdf="$release['hasPdf'] ?? false"
|
||||
:slug="$release['slug']"
|
||||
:imageUrl="$release['imageUrl'] ?? null"
|
||||
:companyLogo="$release['companyLogo'] ?? null"
|
||||
/>
|
||||
<x-web.press-release-card :title="$release['title']" :teaser="$release['teaser']" :company="$release['company']" :industry="$release['industry']"
|
||||
:region="$release['region']" :date="$release['date']" :hasImage="$release['hasImage'] ?? false" :hasPdf="$release['hasPdf'] ?? false" :slug="$release['slug']"
|
||||
:imageUrl="$release['imageUrl'] ?? null" :companyLogo="$release['companyLogo'] ?? null" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue