presseportale/resources/views/livewire/admin/legal-requests/show.blade.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

569 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestExecuted;
use App\Models\LegalRequest;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] class extends Component
{
#[Locked]
public int $id;
public string $adminNote = '';
public string $notification = '';
/** Schwärzbegriffe inkl. Varianten, ein Begriff pro Zeile. */
public string $redactionTerms = '';
public string $redactionReplacement = '';
public bool $detachContacts = true;
/** @var list<int> */
public array $removeImageIds = [];
/** @var array{fields: array<string, array{hits: int, after: string}>, total_hits: int}|null */
public ?array $redactionPreview = null;
public function mount(int $id): void
{
$this->id = $id;
$this->adminNote = (string) ($this->request()->admin_note ?? '');
$this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT;
// Spur B (Phase 5): strukturierter Schwärzungsauftrag aus dem
// verifizierten Web-Formular befüllt den Editor vor — der Admin
// prüft, ergänzt und führt aus.
$redaction = (array) ($this->request()->payload['redaction'] ?? []);
if ($redaction !== []) {
$this->redactionTerms = implode("\n", (array) ($redaction['terms'] ?? []));
$this->detachContacts = (bool) ($redaction['detach_contacts'] ?? true);
}
}
/**
* Pflicht-Vorschau (Konzept §Spur B): Ohne Diff keine Ausführung.
*/
public function previewAnonymization(AnonymizationService $service): void
{
$this->authorize('compliance:manage');
$pressRelease = $this->request()->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($this->redactionTermList() === [] && ! $this->detachContacts && $this->removeImageIds === []) {
$this->addError('redactionTerms', __('Mindestens einen Schwärzbegriff eingeben oder Kontakte/Bilder auswählen.'));
return;
}
$this->redactionPreview = $service->preview(
$pressRelease,
$this->redactionTermList(),
$this->replacementValue(),
);
}
public function executeAnonymization(AnonymizationService $service): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$pressRelease = $request->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($this->redactionPreview === null) {
$this->addError('redactionTerms', __('Bitte zuerst die Vorschau prüfen.'));
return;
}
$service->execute(
$pressRelease,
$this->redactionTermList(),
$this->replacementValue(),
$this->detachContacts,
$this->removeImageIds,
legalRequest: $request,
);
$request->update([
'status' => LegalRequestStatus::Resolved->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'anonymized');
$this->redactionPreview = null;
$this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.');
}
/**
* Alternative zur Schwärzung (Matrix-Test §1/§2): Bei Vanity-/Personen-PMs
* bleibt nach Schwärzung nur ein Rumpf — dann ist Depublizierung das
* mildere Restmittel (im DSGVO-Fall kostenlos, umkehrbar).
*/
public function depublishRelease(PressReleaseService $service): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$pressRelease = $request->pressRelease;
abort_unless($pressRelease !== null, 404);
if ($pressRelease->status !== PressReleaseStatus::Published) {
$this->addError('redactionTerms', __('Nur veröffentlichte PMs können depubliziert werden.'));
return;
}
$service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference(), $request);
$request->update([
'status' => LegalRequestStatus::Resolved->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'depublished');
$this->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.');
}
/**
* Umsetzungs-Bestätigung an die meldende Person (Phase 5) — nur wenn
* eine Antragsteller-Adresse vorliegt (interne Vorgänge haben keine).
*
* @param 'anonymized'|'depublished' $action
*/
private function sendExecutedMail(LegalRequest $request, string $action): void
{
if (! $request->requester_email) {
return;
}
Mail::to($request->requester_email)->queue(new LegalRequestExecuted($request->fresh(), $action));
}
/**
* Rückgängig machen: spielt den verschlüsselten Vorher-Snapshot einer
* ausgeführten Korrektur/Anonymisierung zurück (bis zur Löschfrist).
*/
public function restoreSnapshot(\App\Services\PressRelease\CorrectionService $correctionService): void
{
$this->authorize('compliance:manage');
$request = $this->request();
$snapshot = $request->redactionSnapshots()->latest('id')->first();
abort_unless($snapshot !== null, 404);
$correctionService->restoreSnapshot($snapshot, auth()->user());
$request->update([
'status' => LegalRequestStatus::InProgress->value,
'executed_at' => null,
]);
$this->notification = __('Vorher-Stand wiederhergestellt — Anfrage wieder in Bearbeitung.');
}
/**
* @return list<string>
*/
private function redactionTermList(): array
{
return collect(preg_split('/\r\n|\r|\n/', $this->redactionTerms) ?: [])
->map(fn (string $term): string => trim($term))
->filter()
->unique()
->values()
->all();
}
private function replacementValue(): string
{
return trim($this->redactionReplacement) !== ''
? trim($this->redactionReplacement)
: AnonymizationService::DEFAULT_REPLACEMENT;
}
public function canRedact(LegalRequest $request): bool
{
return $request->pressRelease !== null
&& in_array($request->type, [LegalRequestType::Dsgvo, LegalRequestType::PersonalRights], true);
}
/**
* HTML-Inhalt lesbar und kopierbar machen: Block-Enden zu Zeilenumbrüchen,
* Tags raus — der Admin kopiert Begriffe direkt aus dieser Ansicht.
*/
public function displayValue(?string $value): string
{
// Ohne "?"+">" im Pattern — der Volt-Compiler würde die Sequenz als
// PHP-Schließtag lesen und die Klasse zerschneiden.
$withBreaks = preg_replace('#<br[^>]*>|</(p|div|h[1-6]|li|blockquote)>#i', "\n", (string) $value) ?? '';
return trim(strip_tags($withBreaks));
}
/**
* @return array<string, string>
*/
public function redactableFieldLabels(): array
{
return [
'title' => __('Titel'),
'subtitle' => __('Untertitel'),
'text' => __('Text'),
'boilerplate_override' => __('Boilerplate'),
'keywords' => __('Keywords'),
];
}
public function markInProgress(): void
{
$this->authorize('compliance:manage');
$this->request()->update(['status' => LegalRequestStatus::InProgress->value]);
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
}
public function resolve(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Resolved, __('Anfrage als erledigt markiert.'));
}
public function reject(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Rejected, __('Anfrage abgelehnt.'));
}
public function reopen(): void
{
$this->authorize('compliance:manage');
$this->request()->update([
'status' => LegalRequestStatus::Open->value,
'resolved_by_user_id' => null,
'resolved_at' => null,
]);
$this->notification = __('Anfrage wieder geöffnet.');
}
private function finish(LegalRequestStatus $status, string $message): void
{
$this->request()->update([
'status' => $status->value,
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
'resolved_by_user_id' => auth()->id(),
'resolved_at' => now(),
]);
$this->notification = $message;
}
private function request(): LegalRequest
{
return LegalRequest::query()->with(['pressRelease.images', 'resolver'])->findOrFail($this->id);
}
public function with(): array
{
return ['request' => $this->request()];
}
public function statusBadgeClass(LegalRequestStatus $status): string
{
return match ($status) {
LegalRequestStatus::Open => 'warn',
LegalRequestStatus::InProgress => 'hub',
LegalRequestStatus::Resolved => 'ok',
LegalRequestStatus::Rejected => 'err',
};
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
size="sm"
:badge="__('Admin Backend')"
:eyebrow="__('Recht & Compliance · Anfrage')"
:title="$request->type->label()"
>
<x-slot:meta>
<span class="badge dot {{ $this->statusBadgeClass($request->status) }}">{{ $request->status->label() }}</span>
</x-slot:meta>
<x-slot:actions>
<flux:button icon="arrow-left" variant="filled" href="{{ route('admin.legal-requests.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ $notification }}
</div>
@endif
<div class="grid gap-6 xl:grid-cols-3">
{{-- ============== ANFRAGE ============== --}}
<article class="panel xl:col-span-2">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
</div>
<div class="p-5 space-y-4">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] text-[color:var(--color-ink)] whitespace-pre-line">
{{ $request->message ?: __('Keine Nachricht hinterlegt.') }}
</div>
<dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]">
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Vorgangsnummer') }}</dt>
<dd class="text-[color:var(--color-ink)] font-mono">{{ $request->reference() }}</dd>
</div>
@if ($request->requesterName())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Name') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $request->requesterName() }}</dd>
</div>
@endif
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
<dd class="text-[color:var(--color-ink)] break-all">
@if ($request->requester_email)
<a href="mailto:{{ $request->requester_email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2">{{ $request->requester_email }}</a>
@else
{{ __('anonym') }}
@endif
</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Eingegangen') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $request->created_at?->format('d.m.Y H:i') ?? '-' }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Pressemitteilung') }}</dt>
<dd class="text-[color:var(--color-ink)]">
@if ($request->pressRelease)
{{ $request->pressRelease->title }}
@if (\Illuminate\Support\Facades\Route::has('admin.press-releases.show'))
<a href="{{ route('admin.press-releases.show', $request->pressRelease->id) }}" wire:navigate
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
@endif
@elseif ($request->reportedUrl())
{{ __('Keine PM zugeordnet — gemeldete URL:') }}
<a href="{{ $request->reportedUrl() }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $request->reportedUrl() }}</a>
@else
{{ __('PM wurde gelöscht') }}
@endif
</dd>
</div>
</dl>
</div>
</article>
{{-- ============== ANONYMISIERUNG (Spur B) ============== --}}
@if ($this->canRedact($request) && $request->status->isOpen())
<article class="panel xl:col-span-2">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anonymisierung (Schwärzung)') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Schwärzt personenbezogene Daten über Wortgrenzen (inkl. Varianten wie „Herr Teplitsky", „Teplitskys"). Kostenlos, ohne KI-Gate; Alt-Werte werden verschlüsselt gesichert und die Re-Sync-Sperre gesetzt.') }}
</p>
@if ((array) ($request->payload['redaction'] ?? []) !== [])
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
{{ __('Strukturierter Schwärzungsauftrag aus dem Web-Formular — Begriffe und Optionen sind unten vorbefüllt (:hits Treffer laut Antragsteller-Vorschau).', ['hits' => (int) ($request->payload['redaction']['preview_total_hits'] ?? 0)]) }}
@if ($request->payload['redaction']['remove_images_requested'] ?? false)
<span class="font-semibold">{{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }}</span>
@endif
</div>
@endif
<flux:field>
<flux:textarea wire:model="redactionTerms" rows="4"
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"
:placeholder="'Matvey Teplitsky&#10;Herr Teplitsky&#10;Teplitskys'" />
<flux:error name="redactionTerms" />
</flux:field>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:input wire:model="redactionReplacement"
:label="__('Ersetzungswert (Rolle/Platzhalter, nie leer)')"
:placeholder="\App\Services\PressRelease\AnonymizationService::DEFAULT_REPLACEMENT" />
</flux:field>
<div class="pt-6">
<flux:checkbox wire:model="detachContacts"
:label="__('Kontakt-Zuordnungen der PM lösen (Ansprechpartner raus)')" />
</div>
</div>
@if ($request->pressRelease->images->isNotEmpty())
<flux:field>
<flux:label>{{ __('Bilder entfernen (Klarname in Pressebild!)') }}</flux:label>
<div class="space-y-1">
@foreach ($request->pressRelease->images as $image)
<flux:checkbox wire:model="removeImageIds" value="{{ $image->id }}"
:label="($image->title ?: $image->path)" />
@endforeach
</div>
</flux:field>
@endif
<div class="flex flex-wrap gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button variant="filled" icon="eye" wire:click="previewAnonymization">
{{ $redactionPreview === null ? __('Vorschau erstellen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($redactionPreview !== null)
<flux:button variant="primary" icon="shield-check" wire:click="executeAnonymization"
wire:confirm="{{ __('Schwärzung jetzt ausführen? Alt-Werte werden verschlüsselt gesichert, die Re-Sync-Sperre wird gesetzt.') }}">
{{ __('Anonymisieren ausführen') }}
</flux:button>
@endif
@if ($request->pressRelease->status === \App\Enums\PressReleaseStatus::Published)
<flux:button variant="danger" icon="archive-box-arrow-down" wire:click="depublishRelease"
wire:confirm="{{ __('PM stattdessen depublizieren (Tombstone, umkehrbar)? Richtig, wenn nach Schwärzung nur ein Rumpf bliebe.') }}">
{{ __('Stattdessen depublizieren') }}
</flux:button>
@endif
</div>
{{-- Kompletter PM-Inhalt: vor der Vorschau das Original (Begriffe
von hier kopieren), danach der Stand nach Schwärzung. --}}
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-[12.5px] font-semibold text-[color:var(--color-ink)]">
{{ $redactionPreview === null
? __('Aktueller Inhalt — Begriffe von hier in das Feld oben kopieren')
: __('Vorschau nach Schwärzung') }}
</p>
@if ($redactionPreview !== null)
<span class="badge dot {{ $redactionPreview['total_hits'] > 0 ? 'ok' : 'warn' }}">
{{ __(':count Ersetzung(en)', ['count' => $redactionPreview['total_hits']]) }}
</span>
@endif
</div>
@foreach ($this->redactableFieldLabels() as $field => $label)
@php
$fieldValue = $redactionPreview['fields'][$field]['after']
?? $request->pressRelease->getAttribute($field);
$fieldHits = $redactionPreview['fields'][$field]['hits'] ?? 0;
@endphp
@continue(blank($fieldValue))
<div class="text-[12.5px]">
<span class="font-mono text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)]">
{{ $label }}@if ($fieldHits > 0) · {{ $fieldHits }}× {{ __('ersetzt') }}@endif
</span>
<p class="mt-1 text-[color:var(--color-ink)] whitespace-pre-line">{{ $this->displayValue($fieldValue) }}</p>
</div>
@endforeach
@if ($redactionPreview !== null && $redactionPreview['total_hits'] === 0)
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine Texttreffer — es würden nur Kontakte/Bilder entfernt.') }}
</p>
@endif
</div>
</div>
</article>
@endif
{{-- ============== BEARBEITUNG ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bearbeitung') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:textarea wire:model="adminNote" :label="__('Interne Notiz')" rows="4"
:placeholder="__('Wie wurde die Anfrage bearbeitet?')" />
</flux:field>
@if ($request->resolver)
<p class="text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Zuletzt bearbeitet von :name am :date', [
'name' => $request->resolver->name,
'date' => $request->resolved_at?->format('d.m.Y H:i') ?? '-',
]) }}
</p>
@endif
<dl class="text-[12px] text-[color:var(--color-ink-3)] space-y-1">
<div class="flex justify-between gap-2">
<dt>{{ __('Verifikation') }}</dt>
<dd class="text-[color:var(--color-ink)]">
{{ $request->verification_method?->label() ?? __('nicht verifiziert') }}
@if ($request->verified_at)
· {{ $request->verified_at->format('d.m.Y H:i') }}
@endif
</dd>
</div>
@if ($request->executed_at)
<div class="flex justify-between gap-2">
<dt>{{ __('Ausgeführt') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $request->executed_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
</dl>
@if ($request->executed_at && $request->redactionSnapshots()->exists())
<flux:button variant="filled" icon="arrow-uturn-left" wire:click="restoreSnapshot"
wire:confirm="{{ __('Vorher-Stand aus dem verschlüsselten Snapshot wiederherstellen? Die Re-Sync-Sperre bleibt bestehen.') }}">
{{ __('Rückgängig machen (Snapshot)') }}
</flux:button>
@endif
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
@if ($request->status->isOpen())
@if ($request->status !== \App\Enums\LegalRequestStatus::InProgress)
<flux:button variant="filled" icon="clock" wire:click="markInProgress">
{{ __('In Bearbeitung nehmen') }}
</flux:button>
@endif
<flux:button variant="primary" icon="check" wire:click="resolve">
{{ __('Als erledigt markieren') }}
</flux:button>
<flux:button variant="danger" icon="x-mark" wire:click="reject">
{{ __('Ablehnen') }}
</flux:button>
@else
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
{{ __('Wieder öffnen') }}
</flux:button>
@endif
</div>
</div>
</article>
</div>
</div>