Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
368 lines
14 KiB
PHP
368 lines
14 KiB
PHP
<?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-8">
|
|
<form wire:submit="save" class="space-y-8">
|
|
{{-- ============== PAGE HEADER ============== --}}
|
|
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
|
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
|
<span class="eyebrow muted">{{ __('Administration · Pressekontakte') }}</span>
|
|
<span class="badge hub">ID #{{ $id }}</span>
|
|
<span class="badge hub">{{ $this->currentPortalLabel() }}</span>
|
|
</div>
|
|
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
|
{{ __('Kontakt bearbeiten') }}
|
|
</h1>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
|
{{ __('Zurück') }}
|
|
</flux:button>
|
|
</div>
|
|
</header>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Zuordnung') }}</span>
|
|
</div>
|
|
<div class="p-5 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>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Kontaktdaten') }}</span>
|
|
</div>
|
|
<div class="p-5 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>
|
|
</article>
|
|
|
|
<article class="panel" style="border-left:3px solid var(--color-err);">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow" style="color:var(--color-err);">{{ __('Danger Zone & Aktionen') }}</span>
|
|
</div>
|
|
<div class="p-5 flex justify-between flex-wrap gap-3">
|
|
<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="filled" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
|
{{ __('Abbrechen') }}
|
|
</flux:button>
|
|
<flux:button type="submit" variant="primary">
|
|
{{ __('Speichern') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</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>
|