Add Transaction View

This commit is contained in:
Bob Molitor
2025-10-20 20:48:48 +02:00
parent ff18358b19
commit d17b90bcb9
9 changed files with 769 additions and 6 deletions
@@ -0,0 +1,437 @@
<?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>