Files
AFC-Demo/resources/views/livewire/transaction-review.blade.php
T
2025-10-20 20:48:48 +02:00

438 lines
23 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\Models\Transaction;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app')] class extends Component {
use WithPagination;
public string $search = '';
public string $status = 'all';
public ?int $selectedTransactionId = null;
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => 'all'],
'page' => ['except' => 1],
];
public function mount(): void
{
$this->selectedTransactionId = Transaction::query()
->orderByDesc('risk_score')
->orderByDesc('executed_at')
->value('id');
}
public function with(): array
{
return [
'title' => __('Transaktionsprüfung'),
];
}
public function selectTransaction(int $transactionId): void
{
$this->selectedTransactionId = $transactionId;
}
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatedSearch(): void
{
$this->refreshSelection();
}
public function updatingStatus(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->refreshSelection();
}
public function updatedPage(): void
{
$this->refreshSelection();
}
protected function refreshSelection(): void
{
$first = $this->transactions->first();
$this->selectedTransactionId = $first?->id;
}
#[Computed]
public function statusOptions(): array
{
return [
'all' => 'Alle Prüfaufträge',
Transaction::STATUS_TRUE_POSITIVE => 'Bestätigte Treffer',
Transaction::STATUS_FALSE_POSITIVE => 'Fehlalarme',
Transaction::STATUS_CLEARED => 'Freigegebene Vorgänge',
];
}
#[Computed]
public function transactions(): LengthAwarePaginator
{
return $this->filteredQuery()
->with('company')
->orderByDesc('requires_review')
->orderByDesc('risk_score')
->orderByDesc('executed_at')
->paginate(12);
}
#[Computed]
public function metrics(): array
{
$baseQuery = $this->filteredQuery();
/** @var Collection<int, array{count:int, amount:float}> $grouped */
$grouped = (clone $baseQuery)
->selectRaw('status, COUNT(*) AS total, COALESCE(SUM(amount), 0) AS volume')
->groupBy('status')
->get()
->mapWithKeys(fn ($row) => [
$row->status => [
'count' => (int) $row->total,
'amount' => (float) $row->volume,
],
]);
return [
'by_status' => $grouped->toArray(),
'total' => [
'count' => $grouped->sum('count'),
'amount' => $grouped->sum('amount'),
],
];
}
#[Computed]
public function selectedTransaction(): ?Transaction
{
$transaction = $this->transactions->firstWhere('id', $this->selectedTransactionId);
if (! $transaction && $this->selectedTransactionId) {
$transaction = Transaction::query()
->with('company')
->find($this->selectedTransactionId);
}
return $transaction ?? $this->transactions->first();
}
protected function filteredQuery(): Builder
{
$search = trim($this->search);
return Transaction::query()
->when($this->status !== 'all', fn (Builder $query) => $query->where('status', $this->status))
->when($search !== '', function (Builder $query) use ($search) {
$like = '%' . $search . '%';
$query->where(function (Builder $inner) use ($like) {
$inner->where('reference', 'like', $like)
->orWhere('counterparty', 'like', $like)
->orWhereHas('company', function (Builder $company) use ($like) {
$company->where('name', 'like', $like)
->orWhere('legal_name', 'like', $like)
->orWhere('ticker', 'like', $like);
});
});
});
}
}; ?>
@php
/** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator $transactions */
$transactions = $this->transactions;
/** @var \App\Models\Transaction|null $selected */
$selected = $this->selectedTransaction;
$metrics = $this->metrics;
$byStatus = collect($metrics['by_status']);
$resolveMetrics = static fn (string $status): array => $byStatus->get($status, ['count' => 0, 'amount' => 0.0]);
$formatAmount = static fn (float $value): string => number_format($value, 2, ',', '.') . ' €';
$confirmed = $resolveMetrics(Transaction::STATUS_TRUE_POSITIVE);
$falseAlarm = $resolveMetrics(Transaction::STATUS_FALSE_POSITIVE);
$cleared = $resolveMetrics(Transaction::STATUS_CLEARED);
$total = $metrics['total'];
$statusStyles = [
Transaction::STATUS_TRUE_POSITIVE => 'bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-500/30',
Transaction::STATUS_FALSE_POSITIVE => 'bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30',
Transaction::STATUS_CLEARED => 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30',
];
$statusCopy = [
Transaction::STATUS_TRUE_POSITIVE => 'Zur Sonderprüfung eskalieren und Ermittlungsakte anlegen.',
Transaction::STATUS_FALSE_POSITIVE => 'Anomalie begründen, Dokumentation ergänzen und Vorgang schließen.',
Transaction::STATUS_CLEARED => 'Review-Ergebnis dokumentieren und Kontrollnachweis archivieren.',
];
@endphp
<div class="flex flex-col gap-6">
<div class="rounded-3xl border border-slate-200/60 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 p-8 text-white shadow-xl dark:border-slate-700/60 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
<div class="flex flex-wrap items-end justify-between gap-6">
<div class="space-y-2 max-w-xl">
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">Finanzkriminalitäts-Workbench</h1>
<p class="text-sm text-slate-300 sm:text-base">
Überwachen Sie verdächtige Transaktionen im DAX-Portfolio und steuern Sie das manuelle Review nahtlos.
</p>
</div>
<div class="grid grid-cols-2 gap-4 text-sm sm:text-base md:grid-cols-4">
<div class="rounded-2xl border border-white/10 bg-white/10 p-4 text-center backdrop-blur">
<p class="text-xs font-medium uppercase tracking-wide text-slate-200">Gesamtfälle</p>
<p class="text-2xl font-semibold text-white">{{ $total['count'] }}</p>
<p class="mt-1 text-xs text-slate-300">Volumen: {{ $formatAmount($total['amount']) }}</p>
</div>
<div class="rounded-2xl border border-rose-400/40 bg-rose-500/10 p-4 text-center backdrop-blur">
<p class="text-xs font-medium uppercase tracking-wide text-rose-100">Bestätigte Treffer</p>
<p class="text-2xl font-semibold text-rose-200">{{ $confirmed['count'] }}</p>
<p class="mt-1 text-xs text-rose-100">Volumen: {{ $formatAmount($confirmed['amount']) }}</p>
</div>
<div class="rounded-2xl border border-amber-400/40 bg-amber-500/10 p-4 text-center backdrop-blur">
<p class="text-xs font-medium uppercase tracking-wide text-amber-100">Fehlalarme</p>
<p class="text-2xl font-semibold text-amber-100">{{ $falseAlarm['count'] }}</p>
<p class="mt-1 text-xs text-amber-100">Volumen: {{ $formatAmount($falseAlarm['amount']) }}</p>
</div>
<div class="rounded-2xl border border-emerald-400/40 bg-emerald-500/10 p-4 text-center backdrop-blur">
<p class="text-xs font-medium uppercase tracking-wide text-emerald-100">Freigegeben</p>
<p class="text-2xl font-semibold text-emerald-100">{{ $cleared['count'] }}</p>
<p class="mt-1 text-xs text-emerald-100">Volumen: {{ $formatAmount($cleared['amount']) }}</p>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1">
<h2 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Aktive Alert-Warteschlange</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Alle untenstehenden Meldungen benötigen eine finale Analystenentscheidung.
</p>
</div>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<flux:select wire:model.live="status" class="w-full sm:w-56">
@foreach ($this->statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input
wire:model.debounce.400ms="search"
type="search"
placeholder="Unternehmen, Gegenpartei oder Referenz suchen …"
class="w-full sm:w-80"
icon="magnifying-glass" />
</div>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,2.1fr)_minmax(0,1fr)]">
<div class="overflow-hidden rounded-3xl border border-zinc-200/70 bg-white/95 shadow-sm backdrop-blur dark:border-zinc-700/70 dark:bg-zinc-900/90">
<div class="hidden border-b border-zinc-200/70 bg-zinc-50/80 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-700/60 dark:bg-zinc-900/70 dark:text-zinc-400 sm:grid sm:grid-cols-[1.6fr_1fr_0.7fr]">
<span>Unternehmen &amp; Gegenpartei</span>
<span>Kanal &amp; Referenz</span>
<span class="text-right">Betrag</span>
</div>
<div class="divide-y divide-zinc-200/60 dark:divide-zinc-700/70">
@forelse ($transactions as $transaction)
@php
$selectedState = $transaction->id === $this->selectedTransactionId;
@endphp
<button
wire:click="selectTransaction({{ $transaction->id }})"
type="button"
class="flex w-full flex-col gap-3 px-4 py-4 text-left transition sm:grid sm:grid-cols-[1.6fr_1fr_0.7fr]"
@class([
'bg-indigo-500/10 dark:bg-indigo-500/20 border-s-4 border-indigo-500/80',
'hover:bg-zinc-50/80 dark:hover:bg-zinc-800/70 border-s-transparent' => ! $selectedState,
])
>
<div class="space-y-2">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $transaction->company->legal_name }}</p>
<p class="text-xs uppercase tracking-wide text-zinc-400">{{ $transaction->company->name }} &middot; {{ $transaction->company->sector }}</p>
</div>
<span class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold {{ $statusStyles[$transaction->status] ?? 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300 border border-zinc-500/20' }}">
{{ $transaction->statusLabel() }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span class="font-medium text-zinc-700 dark:text-zinc-200">{{ $transaction->counterparty }}</span>
<span>•</span>
<span>{{ $transaction->counterparty_country }}</span>
<span>•</span>
<span>{{ $transaction->executed_at?->format('d.m.Y H:i') }}</span>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $transaction->channel }}</p>
<p class="font-mono text-xs text-zinc-400">{{ $transaction->reference }}</p>
</div>
<div class="flex flex-col items-end justify-between gap-2">
<p class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{{ number_format($transaction->amount, 2, ',', '.') }} {{ $transaction->currency }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<span class="rounded-md bg-slate-100/60 px-2 py-1 font-semibold text-slate-700 dark:bg-slate-900/60 dark:text-slate-200">
Risiko {{ $transaction->risk_score }}
</span>
<span>{{ $statusCopy[$transaction->status] ?? '' }}</span>
</div>
</div>
</button>
@empty
<div class="px-6 py-16 text-center text-sm text-zinc-500 dark:text-zinc-400">
Keine Transaktionen für die aktuellen Filterkriterien gefunden. Bitte Seed-Daten neu laden oder Filter anpassen.
</div>
@endforelse
</div>
@if ($transactions->hasPages())
<div class="flex items-center justify-between gap-3 border-t border-zinc-200/70 bg-zinc-50/70 px-4 py-3 text-sm text-zinc-500 dark:border-zinc-700/60 dark:bg-zinc-900/70 dark:text-zinc-300">
<button
wire:click="previousPage"
wire:loading.attr="disabled"
@disabled($transactions->onFirstPage())
class="inline-flex items-center gap-2 rounded-full border border-zinc-300/70 bg-white/70 px-4 py-1.5 font-medium text-zinc-700 transition hover:bg-white dark:border-zinc-600/70 dark:bg-zinc-800/80 dark:text-zinc-200 dark:hover:bg-zinc-800/60"
>
‹ Zurück
</button>
<span class="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Seite {{ $transactions->currentPage() }} von {{ $transactions->lastPage() }}
</span>
<button
wire:click="nextPage"
wire:loading.attr="disabled"
@disabled(! $transactions->hasMorePages())
class="inline-flex items-center gap-2 rounded-full border border-zinc-300/70 bg-white/70 px-4 py-1.5 font-medium text-zinc-700 transition hover:bg-white dark:border-zinc-600/70 dark:bg-zinc-800/80 dark:text-zinc-200 dark:hover:bg-zinc-800/60"
>
Weiter ›
</button>
</div>
@endif
</div>
<div class="space-y-4">
@if ($selected)
<div class="rounded-3xl border border-zinc-200/70 bg-white/95 p-6 shadow-sm backdrop-blur dark:border-zinc-700/70 dark:bg-zinc-900/90">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ $selected->company->legal_name }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ $selected->company->summary }}</p>
</div>
<div class="flex flex-col items-end gap-2">
<span class="inline-flex items-center rounded-full bg-slate-900/90 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white dark:bg-slate-200/20">
Prüfung erforderlich
</span>
<span class="text-3xl font-semibold text-slate-900 dark:text-white">
{{ number_format($selected->amount, 2, ',', '.') }} {{ $selected->currency }}
</span>
<div class="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<span class="rounded-md bg-slate-900/10 px-2 py-1 font-semibold text-slate-700 dark:bg-slate-200/10 dark:text-slate-200">
Risikoscore {{ $selected->risk_score }}
</span>
<span>{{ $selected->executed_at?->format('d.m.Y, H:i') }}</span>
</div>
</div>
</div>
<dl class="mt-6 grid grid-cols-1 gap-4 text-sm text-zinc-600 dark:text-zinc-300">
<div class="rounded-2xl bg-zinc-50/80 p-4 dark:bg-zinc-800/80">
<dt class="text-xs uppercase tracking-wide text-zinc-400">Gegenpartei</dt>
<dd class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->counterparty }}</dd>
<dd class="text-xs text-zinc-500">{{ $selected->counterparty_country }}</dd>
</div>
<div class="rounded-2xl bg-zinc-50/80 p-4 dark:bg-zinc-800/80">
<dt class="text-xs uppercase tracking-wide text-zinc-400">Zahlungskanal &amp; Referenz</dt>
<dd class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->channel }}</dd>
<dd class="font-mono text-xs text-zinc-500">{{ $selected->reference }}</dd>
</div>
<div class="rounded-2xl bg-zinc-50/80 p-4 dark:bg-zinc-800/80">
<dt class="text-xs uppercase tracking-wide text-zinc-400">Flagging-Engine</dt>
<dd class="mt-1 font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->flagged_by }}</dd>
<dd class="text-xs text-zinc-500">{{ $selected->flagged_reason }}</dd>
</div>
</dl>
@if (! empty($selected->signals))
<div class="mt-6 space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Detektionssignale</h4>
<div class="flex flex-wrap gap-2">
@foreach ($selected->signals as $signal)
<span class="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-slate-100/60 px-3 py-1 text-xs font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-800/60 dark:text-slate-200">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
{{ data_get($signal, 'type') }} &middot; {{ data_get($signal, 'value') }}
</span>
@endforeach
</div>
</div>
@endif
<div class="mt-6 rounded-2xl border border-indigo-200/60 bg-indigo-500/10 p-4 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-100">
<p class="font-medium">Empfohlene Maßnahme</p>
<p class="mt-1 text-sm leading-relaxed">
{{ $statusCopy[$selected->status] ?? 'Review abschließen und vollständigen Audit-Trail dokumentieren.' }}
</p>
</div>
</div>
<div class="rounded-3xl border border-zinc-200/60 bg-white/95 p-6 shadow-sm backdrop-blur dark:border-zinc-700/70 dark:bg-zinc-900/90">
<h4 class="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Unternehmensprofil</h4>
<ul class="mt-4 space-y-3 text-sm text-zinc-600 dark:text-zinc-300">
<li class="flex items-center justify-between">
<span>Rechtlicher Name</span>
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->company->legal_name }}</span>
</li>
<li class="flex items-center justify-between">
<span>Ticker</span>
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->company->ticker }}</span>
</li>
<li class="flex items-center justify-between">
<span>Sektor</span>
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->company->sector }}</span>
</li>
<li class="flex items-center justify-between">
<span>Hauptsitz</span>
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->company->headquarters }}</span>
</li>
<li class="flex items-center justify-between">
<span>Land</span>
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selected->company->country }}</span>
</li>
<li class="flex items-center justify-between">
<span>KYC-Risikostufe</span>
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{{ Str::title($selected->company->kyc_risk_level) }}
</span>
</li>
</ul>
</div>
@else
<div class="rounded-3xl border border-dashed border-zinc-300/70 bg-white/80 p-6 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-600/50 dark:bg-zinc-900/60 dark:text-zinc-300">
Wählen Sie links einen Alert aus, um den vollständigen Prüfkontext einzusehen.
</div>
@endif
</div>
</div>
</div>