14-04-2026
This commit is contained in:
parent
f58c709945
commit
0f82fea88a
72 changed files with 7414 additions and 148 deletions
125
app/Console/Commands/CheckPaymentUptime.php
Normal file
125
app/Console/Commands/CheckPaymentUptime.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\IncidentActivity;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use App\Models\ProviderUptimeLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CheckPaymentUptime extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'payment:check-uptime';
|
||||||
|
|
||||||
|
protected $description = 'Prüft die Erreichbarkeit von PAYONE und legt bei Ausfall automatisch einen Incident an.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $endpoints = [
|
||||||
|
'payone' => 'https://api.pay1.de/post-gateway/',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
Log::channel('payment')->info('COMMAND [payment:check-uptime] started.');
|
||||||
|
|
||||||
|
foreach ($this->endpoints as $provider => $url) {
|
||||||
|
$this->checkProvider($provider, $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('payment')->info('COMMAND [payment:check-uptime] finished.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkProvider(string $provider, string $url): void
|
||||||
|
{
|
||||||
|
$startMs = now()->valueOf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(10)->head($url);
|
||||||
|
$responseTimeMs = now()->valueOf() - $startMs;
|
||||||
|
$isUp = $response->successful() || $response->status() < 500;
|
||||||
|
$errorMessage = $isUp ? null : 'HTTP '.$response->status();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$responseTimeMs = now()->valueOf() - $startMs;
|
||||||
|
$isUp = false;
|
||||||
|
$errorMessage = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderUptimeLog::create([
|
||||||
|
'provider' => $provider,
|
||||||
|
'is_up' => $isUp,
|
||||||
|
'response_time_ms' => (int) $responseTimeMs,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
'checked_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $isUp ? 'UP' : 'DOWN';
|
||||||
|
$this->line("[{$provider}] {$status} ({$responseTimeMs}ms)");
|
||||||
|
Log::channel('payment')->info("[payment:check-uptime] {$provider} {$status}", [
|
||||||
|
'response_time_ms' => $responseTimeMs,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $isUp) {
|
||||||
|
$this->handleOutage($provider, $errorMessage);
|
||||||
|
} else {
|
||||||
|
$this->handleRecovery($provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleOutage(string $provider, ?string $errorMessage): void
|
||||||
|
{
|
||||||
|
$incident = PaymentIncident::firstOrCreate(
|
||||||
|
[
|
||||||
|
'provider' => $provider,
|
||||||
|
'type' => 'outage',
|
||||||
|
'status' => 'open',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Automatisch: '.strtoupper($provider).' nicht erreichbar',
|
||||||
|
'description' => 'Uptime-Check hat einen Ausfall erkannt.',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'detected_at' => now()->startOfHour(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'note',
|
||||||
|
'title' => 'Uptime-Check: '.strtoupper($provider).' DOWN',
|
||||||
|
'content' => $errorMessage,
|
||||||
|
'author' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::channel('payment')->error("[payment:check-uptime] Outage-Incident #{$incident->id} für {$provider}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleRecovery(string $provider): void
|
||||||
|
{
|
||||||
|
$openOutages = PaymentIncident::where('provider', $provider)
|
||||||
|
->where('type', 'outage')
|
||||||
|
->whereIn('status', ['open', 'in_progress'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($openOutages as $incident) {
|
||||||
|
$incident->update([
|
||||||
|
'status' => 'resolved',
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'status_change',
|
||||||
|
'title' => 'Automatisch aufgelöst: '.strtoupper($provider).' ist wieder erreichbar',
|
||||||
|
'author' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::channel('payment')->info("[payment:check-uptime] Incident #{$incident->id} für {$provider} automatisch aufgelöst.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Console;
|
||||||
use App\Console\Commands\BusinessStore;
|
use App\Console\Commands\BusinessStore;
|
||||||
use App\Console\Commands\BusinessStoreOptimized;
|
use App\Console\Commands\BusinessStoreOptimized;
|
||||||
use App\Console\Commands\CheckPaymentsAccount;
|
use App\Console\Commands\CheckPaymentsAccount;
|
||||||
|
use App\Console\Commands\CheckPaymentUptime;
|
||||||
use App\Console\Commands\DhlUpdateTracking;
|
use App\Console\Commands\DhlUpdateTracking;
|
||||||
use App\Console\Commands\UserCleanup;
|
use App\Console\Commands\UserCleanup;
|
||||||
use App\Console\Commands\UserMakeAboOrder;
|
use App\Console\Commands\UserMakeAboOrder;
|
||||||
|
|
@ -21,6 +22,7 @@ class Kernel extends ConsoleKernel
|
||||||
protected $commands = [
|
protected $commands = [
|
||||||
BusinessStore::class,
|
BusinessStore::class,
|
||||||
BusinessStoreOptimized::class,
|
BusinessStoreOptimized::class,
|
||||||
|
CheckPaymentUptime::class,
|
||||||
CheckPaymentsAccount::class,
|
CheckPaymentsAccount::class,
|
||||||
UserMakeAboOrder::class,
|
UserMakeAboOrder::class,
|
||||||
UserCleanup::class,
|
UserCleanup::class,
|
||||||
|
|
@ -34,6 +36,12 @@ class Kernel extends ConsoleKernel
|
||||||
*/
|
*/
|
||||||
protected function schedule(Schedule $schedule)
|
protected function schedule(Schedule $schedule)
|
||||||
{
|
{
|
||||||
|
// Uptime-Check: PAYONE-Erreichbarkeit alle 5 Minuten prüfen
|
||||||
|
$schedule->command('payment:check-uptime')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
// Job 1: Überprüft täglich um 02:00 Uhr die Zahlungskonten.
|
// Job 1: Überprüft täglich um 02:00 Uhr die Zahlungskonten.
|
||||||
$schedule->command('payments:check-accounts')->dailyAt('02:00');
|
$schedule->command('payments:check-accounts')->dailyAt('02:00');
|
||||||
// Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript.
|
// Jobs 2, 3, 4: Die Befehle aus deinem alten Shell-Skript.
|
||||||
|
|
|
||||||
568
app/Http/Controllers/Admin/PaymentDashboardController.php
Normal file
568
app/Http/Controllers/Admin/PaymentDashboardController.php
Normal file
|
|
@ -0,0 +1,568 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PaymentIncident\AddIncidentActivityRequest;
|
||||||
|
use App\Http\Requests\PaymentIncident\StorePaymentIncidentRequest;
|
||||||
|
use App\Http\Requests\PaymentIncident\UpdateIncidentStatusRequest;
|
||||||
|
use App\Mail\PaymentIncidentAlert;
|
||||||
|
use App\Models\CheckoutFunnelEvent;
|
||||||
|
use App\Models\IncidentActivity;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use App\Models\ProviderUptimeLog;
|
||||||
|
use App\Models\ShoppingOrder;
|
||||||
|
use App\Models\ShoppingPayment;
|
||||||
|
use App\Services\MyLog;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PaymentDashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->middleware('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entwickler-Ansicht mit allen Incidents, Live-Transaktionen und Logs.
|
||||||
|
*/
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$stats = $this->getIncidentStats();
|
||||||
|
$transactionStats = $this->getTransactionStats(7);
|
||||||
|
$openIncidents = PaymentIncident::open()->with('activities')->orderBy('detected_at', 'desc')->get();
|
||||||
|
$allIncidents = PaymentIncident::with('activities')->orderBy('detected_at', 'desc')->paginate(20);
|
||||||
|
$providerStats = $this->getProviderStats();
|
||||||
|
$uptimeStats = $this->getUptimeStats();
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.index', compact(
|
||||||
|
'stats',
|
||||||
|
'transactionStats',
|
||||||
|
'openIncidents',
|
||||||
|
'allIncidents',
|
||||||
|
'providerStats',
|
||||||
|
'uptimeStats',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GF-Ansicht: vereinfacht, nur lesen. Nur für Super-Admins (admin >= 3).
|
||||||
|
*/
|
||||||
|
public function management(): View
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->isSuperAdmin(), 403);
|
||||||
|
$stats = $this->getIncidentStats();
|
||||||
|
$transactionStats = $this->getTransactionStats(30);
|
||||||
|
$openIncidents = PaymentIncident::open()->orderBy('detected_at', 'desc')->get();
|
||||||
|
$recentIncidents = PaymentIncident::orderBy('detected_at', 'desc')->take(10)->get();
|
||||||
|
$providerStats = $this->getProviderStats();
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.management', compact(
|
||||||
|
'stats',
|
||||||
|
'transactionStats',
|
||||||
|
'openIncidents',
|
||||||
|
'recentIncidents',
|
||||||
|
'providerStats',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incident-Detail mit Timeline.
|
||||||
|
*/
|
||||||
|
public function show(PaymentIncident $incident): View
|
||||||
|
{
|
||||||
|
$incident->load('activities');
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.show', compact('incident'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuen Incident anlegen.
|
||||||
|
*/
|
||||||
|
public function store(StorePaymentIncidentRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$incident = PaymentIncident::create($request->validated());
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'note',
|
||||||
|
'title' => 'Incident eröffnet',
|
||||||
|
'content' => $request->validated('description'),
|
||||||
|
'author' => auth()->user()->name ?? 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($incident->severity === 'critical') {
|
||||||
|
MyLog::writeLog(
|
||||||
|
'payment',
|
||||||
|
'error',
|
||||||
|
'Kritischer Zahlungs-Incident eröffnet: '.$incident->title,
|
||||||
|
$request->validated(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Mail::to(config('app.exception_mail'))
|
||||||
|
->queue(new PaymentIncidentAlert($incident));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->invalidateIncidentCache();
|
||||||
|
|
||||||
|
return redirect()->route('admin.payment-dashboard.index')
|
||||||
|
->with('success', 'Incident erfolgreich angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktivität zu einem Incident hinzufügen.
|
||||||
|
*/
|
||||||
|
public function addActivity(AddIncidentActivityRequest $request, PaymentIncident $incident): RedirectResponse
|
||||||
|
{
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => $request->validated('type'),
|
||||||
|
'title' => $request->validated('title'),
|
||||||
|
'content' => $request->validated('content'),
|
||||||
|
'author' => auth()->user()->name ?? 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($incident->status === 'open') {
|
||||||
|
$incident->update(['status' => 'in_progress']);
|
||||||
|
$this->invalidateIncidentCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Aktivität hinzugefügt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status eines Incidents ändern.
|
||||||
|
*/
|
||||||
|
public function updateStatus(UpdateIncidentStatusRequest $request, PaymentIncident $incident): RedirectResponse
|
||||||
|
{
|
||||||
|
|
||||||
|
$oldLabel = $incident->status_label;
|
||||||
|
$incident->update([
|
||||||
|
'status' => $request->status,
|
||||||
|
'resolved_at' => in_array($request->status, ['resolved', 'closed']) ? now() : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'status_change',
|
||||||
|
'title' => 'Status geändert: '.$oldLabel.' → '.$incident->fresh()->status_label,
|
||||||
|
'author' => auth()->user()->name ?? 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invalidateIncidentCache();
|
||||||
|
|
||||||
|
return back()->with('success', 'Status aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Transaktionsdaten aus payment_transactions.
|
||||||
|
*/
|
||||||
|
public function transactions(): View
|
||||||
|
{
|
||||||
|
$days = request()->integer('days', 7);
|
||||||
|
$txactionFilter = request()->string('txaction', '');
|
||||||
|
|
||||||
|
$query = PaymentTransaction::with('shopping_payment')
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($days > 0) {
|
||||||
|
$query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($txactionFilter && $txactionFilter !== '') {
|
||||||
|
$query->where('txaction', $txactionFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $query->paginate(30)->withQueryString();
|
||||||
|
$transactionStats = $this->getTransactionStats($days > 0 ? $days : 30);
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.transactions', compact('transactions', 'transactionStats', 'days', 'txactionFilter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout-Funnel-Tracking: Übersicht der 5 Funnel-Schritte.
|
||||||
|
*/
|
||||||
|
public function funnel(): View
|
||||||
|
{
|
||||||
|
$days = request()->integer('days', 30);
|
||||||
|
$since = $days > 0 ? now()->subDays($days) : null;
|
||||||
|
$filterEvent = request()->string('event', '');
|
||||||
|
$filterStatus = request()->string('return_status', '');
|
||||||
|
$filterSource = request()->string('source', '');
|
||||||
|
|
||||||
|
// ── Funnel-Schritte ──────────────────────────────────────────────────
|
||||||
|
$steps = ['checkout_visited', 'form_submitted', 'payment_initiated', 'payment_returned', 'payment_confirmed'];
|
||||||
|
|
||||||
|
$counts = CheckoutFunnelEvent::query()
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->selectRaw('event, COUNT(*) as count')
|
||||||
|
->groupBy('event')
|
||||||
|
->pluck('count', 'event');
|
||||||
|
|
||||||
|
$funnelSteps = array_map(fn (string $step) => [
|
||||||
|
'event' => $step,
|
||||||
|
'label' => CheckoutFunnelEvent::eventLabels()[$step],
|
||||||
|
'count' => (int) ($counts[$step] ?? 0),
|
||||||
|
], $steps);
|
||||||
|
|
||||||
|
foreach ($funnelSteps as $i => &$step) {
|
||||||
|
if ($i === 0 || $funnelSteps[$i - 1]['count'] === 0) {
|
||||||
|
$step['conversion'] = null;
|
||||||
|
} else {
|
||||||
|
$step['conversion'] = round($step['count'] / $funnelSteps[$i - 1]['count'] * 100, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($step);
|
||||||
|
|
||||||
|
// ── Rückkehr-Status ───────────────────────────────────────────────────
|
||||||
|
$returnStats = CheckoutFunnelEvent::query()
|
||||||
|
->where('event', 'payment_returned')
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->selectRaw('return_status, COUNT(*) as count')
|
||||||
|
->groupBy('return_status')
|
||||||
|
->pluck('count', 'return_status');
|
||||||
|
|
||||||
|
// ── Quelle (Source) Breakdown ─────────────────────────────────────────
|
||||||
|
// Klassifizierung über Domain-Präfix direkt in SQL
|
||||||
|
$sourceRaw = CheckoutFunnelEvent::query()
|
||||||
|
->where('event', 'checkout_visited')
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->selectRaw("
|
||||||
|
CASE
|
||||||
|
WHEN domain LIKE 'my.%' THEN 'salescenter'
|
||||||
|
WHEN domain LIKE 'in.%' THEN 'beraterzugang'
|
||||||
|
WHEN domain LIKE '%.test' THEN 'testserver'
|
||||||
|
WHEN domain LIKE '%.care'
|
||||||
|
OR domain LIKE '%.shop' THEN 'kundenshop'
|
||||||
|
ELSE 'unbekannt'
|
||||||
|
END AS source_type,
|
||||||
|
COUNT(*) as count
|
||||||
|
")
|
||||||
|
->groupBy('source_type')
|
||||||
|
->pluck('count', 'source_type');
|
||||||
|
|
||||||
|
$sourceBreakdown = collect(CheckoutFunnelEvent::sourceLabels())
|
||||||
|
->map(fn ($label, $key) => [
|
||||||
|
'label' => $label,
|
||||||
|
'count' => (int) ($sourceRaw[$key] ?? 0),
|
||||||
|
])
|
||||||
|
->filter(fn ($s) => $s['count'] > 0);
|
||||||
|
|
||||||
|
// ── Ereignisse (gefiltert, paginiert) ─────────────────────────────────
|
||||||
|
$eventsQuery = CheckoutFunnelEvent::with(['shopping_order', 'consultant'])
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->when($filterEvent !== '', fn ($q) => $q->where('event', $filterEvent))
|
||||||
|
->when($filterStatus !== '', fn ($q) => $q->where('return_status', $filterStatus))
|
||||||
|
->when($filterSource !== '', function ($q) use ($filterSource) {
|
||||||
|
return match ($filterSource) {
|
||||||
|
'salescenter' => $q->where('domain', 'like', 'my.%'),
|
||||||
|
'beraterzugang' => $q->where('domain', 'like', 'in.%'),
|
||||||
|
'testserver' => $q->where('domain', 'like', '%.test'),
|
||||||
|
'kundenshop' => $q->where(fn ($s) => $s->where('domain', 'like', '%.care')->orWhere('domain', 'like', '%.shop')),
|
||||||
|
default => $q,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
->latest();
|
||||||
|
|
||||||
|
$recentEvents = $eventsQuery->paginate(50)->withQueryString();
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.funnel', compact(
|
||||||
|
'funnelSteps',
|
||||||
|
'returnStats',
|
||||||
|
'sourceBreakdown',
|
||||||
|
'recentEvents',
|
||||||
|
'days',
|
||||||
|
'filterEvent',
|
||||||
|
'filterStatus',
|
||||||
|
'filterSource',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abbruch-Analyse: Orders ohne Payment, abgebrochene und nicht bestätigte Payments.
|
||||||
|
*/
|
||||||
|
public function abandoned(): View
|
||||||
|
{
|
||||||
|
$days = request()->integer('days', 30);
|
||||||
|
$since = $days > 0 ? now()->subDays($days) : null;
|
||||||
|
|
||||||
|
// Tab 1: Orders mit txaction='prev' ohne ShoppingPayment – Zahlung nie gestartet
|
||||||
|
// Mindestens 30 Minuten alt, damit laufende Checkouts nicht erscheinen
|
||||||
|
$ordersWithoutPayment = ShoppingOrder::with(['auth_user', 'shopping_user'])
|
||||||
|
->where('txaction', 'prev')
|
||||||
|
->doesntHave('shopping_payments')
|
||||||
|
->where('created_at', '<=', now()->subMinutes(30))
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20, ['*'], 'page_no_payment')
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
// Tab 2: ShoppingPayments mit status = 'cancel' oder 'error'
|
||||||
|
$cancelledPayments = ShoppingPayment::with([
|
||||||
|
'shopping_order.auth_user',
|
||||||
|
'shopping_order.shopping_user',
|
||||||
|
'payment_transactions',
|
||||||
|
])
|
||||||
|
->whereIn('status', ['cancel', 'error'])
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20, ['*'], 'page_cancelled')
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
// Tab 3: ShoppingPayments ohne status (kein Callback) – mindestens 2 Stunden alt
|
||||||
|
$pendingPayments = ShoppingPayment::with([
|
||||||
|
'shopping_order.auth_user',
|
||||||
|
'shopping_order.shopping_user',
|
||||||
|
'payment_transactions',
|
||||||
|
])
|
||||||
|
->whereNull('status')
|
||||||
|
->doesntHave('payment_transactions')
|
||||||
|
->where('created_at', '<=', now()->subHours(2))
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20, ['*'], 'page_pending')
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$abandonedStats = [
|
||||||
|
'no_payment' => ShoppingOrder::where('txaction', 'prev')
|
||||||
|
->doesntHave('shopping_payments')
|
||||||
|
->where('created_at', '<=', now()->subMinutes(30))
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->count(),
|
||||||
|
'cancelled' => ShoppingPayment::whereIn('status', ['cancel', 'error'])
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->count(),
|
||||||
|
'no_callback' => ShoppingPayment::whereNull('status')
|
||||||
|
->doesntHave('payment_transactions')
|
||||||
|
->where('created_at', '<=', now()->subHours(2))
|
||||||
|
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.abandoned', compact(
|
||||||
|
'ordersWithoutPayment',
|
||||||
|
'cancelledPayments',
|
||||||
|
'pendingPayments',
|
||||||
|
'abandonedStats',
|
||||||
|
'days',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahlung-zentrierte Übersicht: ShoppingPayments mit Transaktionen und Bestellung.
|
||||||
|
*/
|
||||||
|
public function payments(): View
|
||||||
|
{
|
||||||
|
$days = request()->integer('days', 7);
|
||||||
|
$statusFilter = request()->string('txaction', '')->toString();
|
||||||
|
$modeFilter = request()->string('mode', '')->toString();
|
||||||
|
|
||||||
|
$query = ShoppingPayment::with([
|
||||||
|
'payment_transactions' => fn ($q) => $q->orderBy('created_at'),
|
||||||
|
'shopping_order.auth_user',
|
||||||
|
'shopping_order.shopping_user',
|
||||||
|
])->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($days > 0) {
|
||||||
|
$query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statusFilter !== '') {
|
||||||
|
$query->where('txaction', $statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($modeFilter !== '') {
|
||||||
|
$query->where('mode', $modeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payments = $query->paginate(25)->withQueryString();
|
||||||
|
|
||||||
|
$paymentStats = $this->getPaymentStats($days > 0 ? $days : 30);
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.payments', compact('payments', 'paymentStats', 'days', 'statusFilter', 'modeFilter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAYONE Log-Viewer.
|
||||||
|
*/
|
||||||
|
public function logs(): View
|
||||||
|
{
|
||||||
|
$availableDates = $this->getAvailableLogDates();
|
||||||
|
$selectedDate = request()->string('date', now()->format('Y-m-d'))->toString();
|
||||||
|
|
||||||
|
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDate) || ! in_array($selectedDate, $availableDates)) {
|
||||||
|
$selectedDate = ! empty($availableDates) ? $availableDates[0] : now()->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = storage_path('logs/payone-'.$selectedDate.'.log');
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if (file_exists($logFile)) {
|
||||||
|
$lines = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
||||||
|
foreach (array_slice($lines, 0, 300) as $line) {
|
||||||
|
if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[^\]]*)\] \w+\.(\w+): (.+)$/', $line, $m)) {
|
||||||
|
$entries[] = [
|
||||||
|
'timestamp' => $m[1],
|
||||||
|
'level' => $m[2],
|
||||||
|
'message' => $m[3],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.logs', compact('entries', 'availableDates', 'selectedDate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function getIncidentStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'open_incidents' => PaymentIncident::whereIn('status', ['open', 'waiting_provider'])->count(),
|
||||||
|
'in_progress' => PaymentIncident::where('status', 'in_progress')->count(),
|
||||||
|
'resolved_this_month' => PaymentIncident::where('status', 'resolved')
|
||||||
|
->whereMonth('resolved_at', now()->month)->count(),
|
||||||
|
'total_affected_revenue' => PaymentIncident::open()->sum('affected_revenue'),
|
||||||
|
'payone_incidents_30d' => PaymentIncident::payone()->lastDays(30)->count(),
|
||||||
|
'critical_open' => PaymentIncident::open()->where('severity', 'critical')->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTransactionStats(int $days): array
|
||||||
|
{
|
||||||
|
$since = now()->subDays($days);
|
||||||
|
|
||||||
|
$total = PaymentTransaction::where('created_at', '>=', $since)->count();
|
||||||
|
$paid = PaymentTransaction::where('txaction', 'paid')->where('created_at', '>=', $since)->count();
|
||||||
|
$failed = PaymentTransaction::where('txaction', 'failed')->where('created_at', '>=', $since)->count();
|
||||||
|
$successRate = $total > 0 ? round(($paid / $total) * 100, 1) : 0;
|
||||||
|
|
||||||
|
$errorDistribution = PaymentTransaction::whereNotNull('errorcode')
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->select('errorcode', 'errormessage', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('errorcode', 'errormessage')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$lastFailed = PaymentTransaction::where('txaction', 'failed')->latest()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'paid' => $paid,
|
||||||
|
'failed' => $failed,
|
||||||
|
'success_rate' => $successRate,
|
||||||
|
'error_distribution' => $errorDistribution,
|
||||||
|
'last_failed' => $lastFailed,
|
||||||
|
'days' => $days,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getProviderStats(): array
|
||||||
|
{
|
||||||
|
$providers = [
|
||||||
|
'payone',
|
||||||
|
// 'stripe', // aktuell nicht aktiv
|
||||||
|
'paypal',
|
||||||
|
// 'mollie', // aktuell nicht aktiv
|
||||||
|
'other',
|
||||||
|
];
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$stats[$provider] = [
|
||||||
|
'label' => strtoupper($provider),
|
||||||
|
'open_incidents' => PaymentIncident::where('provider', $provider)->open()->count(),
|
||||||
|
'total_30d' => PaymentIncident::where('provider', $provider)->lastDays(30)->count(),
|
||||||
|
'last_incident' => PaymentIncident::where('provider', $provider)
|
||||||
|
->orderBy('detected_at', 'desc')
|
||||||
|
->first(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, paid: int, failed: int, pending: int, total_amount: float, failed_amount: float}
|
||||||
|
*/
|
||||||
|
private function getPaymentStats(int $days): array
|
||||||
|
{
|
||||||
|
$since = now()->subDays($days);
|
||||||
|
|
||||||
|
$base = ShoppingPayment::where('created_at', '>=', $since);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $base)->count(),
|
||||||
|
'paid' => (clone $base)->where('txaction', 'paid')->count(),
|
||||||
|
'failed' => (clone $base)->whereHas('payment_transactions', fn ($q) => $q->where('txaction', 'failed'))->count(),
|
||||||
|
'pending' => (clone $base)->whereNotIn('txaction', ['paid', 'failed'])->count(),
|
||||||
|
'total_amount' => round((clone $base)->sum('amount') / 100, 2),
|
||||||
|
'failed_amount' => round(
|
||||||
|
(clone $base)->whereHas('payment_transactions', fn ($q) => $q->where('txaction', 'failed'))->sum('amount') / 100,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'days' => $days,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAvailableLogDates(): array
|
||||||
|
{
|
||||||
|
$logPath = storage_path('logs');
|
||||||
|
$files = glob($logPath.'/payone-*.log');
|
||||||
|
$dates = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (preg_match('/payone-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) {
|
||||||
|
$dates[] = $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsort($dates);
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{last_check: ProviderUptimeLog|null, uptime_24h: float, checks_24h: int, failures_24h: int}>
|
||||||
|
*/
|
||||||
|
private function getUptimeStats(): array
|
||||||
|
{
|
||||||
|
$providers = [
|
||||||
|
'payone',
|
||||||
|
// 'stripe', // aktuell nicht aktiv
|
||||||
|
'paypal',
|
||||||
|
// 'mollie', // aktuell nicht aktiv
|
||||||
|
];
|
||||||
|
$stats = [];
|
||||||
|
$since = now()->subHours(24);
|
||||||
|
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$recentLogs = ProviderUptimeLog::where('provider', $provider)
|
||||||
|
->where('checked_at', '>=', $since)
|
||||||
|
->orderBy('checked_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$total = $recentLogs->count();
|
||||||
|
$upCount = $recentLogs->where('is_up', true)->count();
|
||||||
|
|
||||||
|
$stats[$provider] = [
|
||||||
|
'last_check' => ProviderUptimeLog::where('provider', $provider)->latest('checked_at')->first(),
|
||||||
|
'uptime_24h' => $total > 0 ? round(($upCount / $total) * 100, 1) : null,
|
||||||
|
'checks_24h' => $total,
|
||||||
|
'failures_24h' => $total - $upCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateIncidentCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget('open_incident_count');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Models\PaymentTransaction;
|
use App\Models\PaymentTransaction;
|
||||||
use App\Models\ShoppingOrder;
|
use App\Models\ShoppingOrder;
|
||||||
use App\Models\ShoppingPayment;
|
use App\Models\ShoppingPayment;
|
||||||
|
use App\Services\CheckoutFunnelTracker;
|
||||||
use App\Services\MyLog;
|
use App\Services\MyLog;
|
||||||
use App\Services\Payment;
|
use App\Services\Payment;
|
||||||
use App\Services\ShoppingUserService;
|
use App\Services\ShoppingUserService;
|
||||||
|
|
@ -152,6 +153,9 @@ class PayoneController extends Controller
|
||||||
'txaction' => $data['txaction'],
|
'txaction' => $data['txaction'],
|
||||||
'transmitted_data' => Util::utf8ize($data),
|
'transmitted_data' => Util::utf8ize($data),
|
||||||
'mode' => $data['mode'],
|
'mode' => $data['mode'],
|
||||||
|
'errorcode' => $data['errorcode'] ?? null,
|
||||||
|
'errormessage' => $data['errormessage'] ?? null,
|
||||||
|
'customermessage' => $data['customermessage'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Define txaction priority (higher number = higher priority)
|
// Define txaction priority (higher number = higher priority)
|
||||||
|
|
@ -219,6 +223,20 @@ class PayoneController extends Controller
|
||||||
);
|
);
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::confirmedPayment(
|
||||||
|
shoppingPaymentId: $shopping_payment->id,
|
||||||
|
txaction: 'paid',
|
||||||
|
metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['txaction'] === 'appointed') {
|
||||||
|
CheckoutFunnelTracker::confirmedPayment(
|
||||||
|
shoppingPaymentId: $shopping_payment->id,
|
||||||
|
txaction: 'appointed',
|
||||||
|
metadata: ['mode' => $data['mode'] ?? null],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['send_link'] = $send_link;
|
$data['send_link'] = $send_link;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ namespace App\Http\Controllers\Pay;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\PaymentTransaction;
|
use App\Models\PaymentTransaction;
|
||||||
use App\Models\ShoppingPayment;
|
use App\Models\ShoppingPayment;
|
||||||
|
use App\Services\CheckoutFunnelTracker;
|
||||||
use App\Services\MyLog;
|
use App\Services\MyLog;
|
||||||
use App\Services\Payone;
|
use App\Services\Payone;
|
||||||
use Util;
|
use Util;
|
||||||
|
|
@ -189,6 +190,15 @@ class PayoneController extends Controller
|
||||||
'mode' => $this->shopping_order->mode,
|
'mode' => $this->shopping_order->mode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::initiatedPayment(
|
||||||
|
shoppingUserId: $this->shopping_user->id,
|
||||||
|
shoppingOrderId: $this->shopping_order->id,
|
||||||
|
shoppingPaymentId: $this->shopping_payment->id,
|
||||||
|
consultantUserId: $this->shopping_user->auth_user_id ?? null,
|
||||||
|
paymentMethod: $payment_method,
|
||||||
|
amountCents: $amount,
|
||||||
|
);
|
||||||
|
|
||||||
$this->default['mode'] = $this->shopping_order->mode;
|
$this->default['mode'] = $this->shopping_order->mode;
|
||||||
|
|
||||||
return $this->reference;
|
return $this->reference;
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Auth;
|
|
||||||
use Request;
|
|
||||||
use App\Models\UserInvoice;
|
|
||||||
use App\Models\UserCredit;
|
|
||||||
use App\Services\HTMLHelper;
|
|
||||||
use App\Exports\UserTeamExport;
|
use App\Exports\UserTeamExport;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Models\UserCredit;
|
||||||
|
use App\Models\UserInvoice;
|
||||||
|
use App\Services\HTMLHelper;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Request;
|
||||||
|
|
||||||
class RevenueReportController extends Controller
|
class RevenueReportController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +25,7 @@ class RevenueReportController extends Controller
|
||||||
'filter_months' => HTMLHelper::getTransMonths(),
|
'filter_months' => HTMLHelper::getTransMonths(),
|
||||||
'filter_years' => HTMLHelper::getYearRange(2022),
|
'filter_years' => HTMLHelper::getYearRange(2022),
|
||||||
'revenue_summary' => $this->getRevenueSummary(),
|
'revenue_summary' => $this->getRevenueSummary(),
|
||||||
'credit_summary' => $this->getCreditSummary()
|
'credit_summary' => $this->getCreditSummary(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('admin.revenue.index', $data);
|
return view('admin.revenue.index', $data);
|
||||||
|
|
@ -57,7 +55,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => $item->period_label,
|
'Typ' => $item->period_label,
|
||||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -65,7 +63,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => "Jahr {$filter_year}",
|
'Typ' => "Jahr {$filter_year}",
|
||||||
'Netto' => '0,00',
|
'Netto' => '0,00',
|
||||||
'Steuer' => '0,00',
|
'Steuer' => '0,00',
|
||||||
'Brutto' => '0,00'
|
'Brutto' => '0,00',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +78,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => $item->period_label,
|
'Typ' => $item->period_label,
|
||||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -88,7 +86,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => 'Keine monatlichen Umsätze gefunden',
|
'Typ' => 'Keine monatlichen Umsätze gefunden',
|
||||||
'Netto' => '',
|
'Netto' => '',
|
||||||
'Steuer' => '',
|
'Steuer' => '',
|
||||||
'Brutto' => ''
|
'Brutto' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +104,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => $item->period_label,
|
'Typ' => $item->period_label,
|
||||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -114,7 +112,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => "Jahr {$filter_year}",
|
'Typ' => "Jahr {$filter_year}",
|
||||||
'Netto' => '0,00',
|
'Netto' => '0,00',
|
||||||
'Steuer' => '0,00',
|
'Steuer' => '0,00',
|
||||||
'Brutto' => '0,00'
|
'Brutto' => '0,00',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +127,7 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => $item->period_label,
|
'Typ' => $item->period_label,
|
||||||
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
'Brutto' => number_format($item->total_gross, 2, ',', '.')
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -137,10 +135,92 @@ class RevenueReportController extends Controller
|
||||||
'Typ' => 'Keine monatlichen Gutschriften gefunden',
|
'Typ' => 'Keine monatlichen Gutschriften gefunden',
|
||||||
'Netto' => '',
|
'Netto' => '',
|
||||||
'Steuer' => '',
|
'Steuer' => '',
|
||||||
'Brutto' => ''
|
'Brutto' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two empty rows for separation
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
|
||||||
|
// Umsätze nach Ländern - Jährlich
|
||||||
|
$columns[] = ['Typ' => 'UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
if (isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0) {
|
||||||
|
foreach ($revenue_summary['country_yearly'] as $item) {
|
||||||
|
$columns[] = [
|
||||||
|
'Typ' => $item->country_name,
|
||||||
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$columns[] = ['Typ' => 'Keine Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty row
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
|
||||||
|
// Umsätze nach Ländern - Monatlich
|
||||||
|
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG UMSÄTZE NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
if (isset($revenue_summary['country_monthly']) && $revenue_summary['country_monthly']->count() > 0) {
|
||||||
|
$revenueByMonth = $revenue_summary['country_monthly']->groupBy('month');
|
||||||
|
foreach ($revenueByMonth as $countries) {
|
||||||
|
$columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
foreach ($countries as $item) {
|
||||||
|
$columns[] = [
|
||||||
|
'Typ' => ' '.$item->country_name,
|
||||||
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$columns[] = ['Typ' => 'Keine monatlichen Umsätze nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two empty rows for separation
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
|
||||||
|
// Gutschriften nach Ländern - Jährlich
|
||||||
|
$columns[] = ['Typ' => 'GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
if (isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0) {
|
||||||
|
foreach ($credit_summary['country_yearly'] as $item) {
|
||||||
|
$columns[] = [
|
||||||
|
'Typ' => $item->country_name,
|
||||||
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$columns[] = ['Typ' => 'Keine Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty row
|
||||||
|
$columns[] = ['Typ' => '', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
|
||||||
|
// Gutschriften nach Ländern - Monatlich
|
||||||
|
$columns[] = ['Typ' => 'MONATLICHE AUFSCHLÜSSELUNG GUTSCHRIFTEN NACH LÄNDERN', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
if (isset($credit_summary['country_monthly']) && $credit_summary['country_monthly']->count() > 0) {
|
||||||
|
$creditByMonth = $credit_summary['country_monthly']->groupBy('month');
|
||||||
|
foreach ($creditByMonth as $countries) {
|
||||||
|
$columns[] = ['Typ' => $countries->first()->month_label, 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
foreach ($countries as $item) {
|
||||||
|
$columns[] = [
|
||||||
|
'Typ' => ' '.$item->country_name,
|
||||||
|
'Netto' => number_format($item->total_net, 2, ',', '.'),
|
||||||
|
'Steuer' => number_format($item->total_tax, 2, ',', '.'),
|
||||||
|
'Brutto' => number_format($item->total_gross, 2, ',', '.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$columns[] = ['Typ' => 'Keine monatlichen Gutschriften nach Ländern gefunden', 'Netto' => '', 'Steuer' => '', 'Brutto' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
$headers = ['Zeitraum', 'Netto (€)', 'Steuer (€)', 'Brutto (€)'];
|
$headers = ['Zeitraum', 'Netto (€)', 'Steuer (€)', 'Brutto (€)'];
|
||||||
|
|
||||||
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xlsx');
|
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xlsx');
|
||||||
|
|
@ -174,7 +254,9 @@ class RevenueReportController extends Controller
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'yearly' => $this->getRevenueByYear($year),
|
'yearly' => $this->getRevenueByYear($year),
|
||||||
'monthly' => $this->getRevenueByMonthsInYear($year)
|
'monthly' => $this->getRevenueByMonthsInYear($year),
|
||||||
|
'country_yearly' => $this->getRevenueByCountryYear($year),
|
||||||
|
'country_monthly' => $this->getRevenueByCountryMonthly($year),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,7 +266,9 @@ class RevenueReportController extends Controller
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'yearly' => $this->getCreditByYear($year),
|
'yearly' => $this->getCreditByYear($year),
|
||||||
'monthly' => $this->getCreditByMonthsInYear($year)
|
'monthly' => $this->getCreditByMonthsInYear($year),
|
||||||
|
'country_yearly' => $this->getCreditByCountryYear($year),
|
||||||
|
'country_monthly' => $this->getCreditByCountryMonthly($year),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,4 +424,116 @@ class RevenueReportController extends Controller
|
||||||
->orderBy('month')
|
->orderBy('month')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getRevenueByCountryYear(int $year)
|
||||||
|
{
|
||||||
|
return UserInvoice::join('shopping_orders', 'user_invoices.shopping_order_id', '=', 'shopping_orders.id')
|
||||||
|
->join('shipping_countries', 'shopping_orders.country_id', '=', 'shipping_countries.id')
|
||||||
|
->join('countries', 'shipping_countries.country_id', '=', 'countries.id')
|
||||||
|
->selectRaw('
|
||||||
|
countries.id as country_id,
|
||||||
|
countries.de as country_name,
|
||||||
|
countries.code as country_code,
|
||||||
|
SUM(shopping_orders.subtotal_ws) as total_net,
|
||||||
|
SUM(shopping_orders.tax) as total_tax,
|
||||||
|
SUM(shopping_orders.total_shipping) as total_gross
|
||||||
|
')
|
||||||
|
->where('user_invoices.year', $year)
|
||||||
|
->where('user_invoices.cancellation', false)
|
||||||
|
->groupBy('countries.id', 'countries.de', 'countries.code')
|
||||||
|
->orderByDesc('total_gross')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRevenueByCountryMonthly(int $year)
|
||||||
|
{
|
||||||
|
return UserInvoice::join('shopping_orders', 'user_invoices.shopping_order_id', '=', 'shopping_orders.id')
|
||||||
|
->join('shipping_countries', 'shopping_orders.country_id', '=', 'shipping_countries.id')
|
||||||
|
->join('countries', 'shipping_countries.country_id', '=', 'countries.id')
|
||||||
|
->selectRaw('
|
||||||
|
user_invoices.month,
|
||||||
|
CONCAT(CASE user_invoices.month
|
||||||
|
WHEN 1 THEN \'Januar\'
|
||||||
|
WHEN 2 THEN \'Februar\'
|
||||||
|
WHEN 3 THEN \'März\'
|
||||||
|
WHEN 4 THEN \'April\'
|
||||||
|
WHEN 5 THEN \'Mai\'
|
||||||
|
WHEN 6 THEN \'Juni\'
|
||||||
|
WHEN 7 THEN \'Juli\'
|
||||||
|
WHEN 8 THEN \'August\'
|
||||||
|
WHEN 9 THEN \'September\'
|
||||||
|
WHEN 10 THEN \'Oktober\'
|
||||||
|
WHEN 11 THEN \'November\'
|
||||||
|
WHEN 12 THEN \'Dezember\'
|
||||||
|
END, \' \', user_invoices.year) as month_label,
|
||||||
|
countries.id as country_id,
|
||||||
|
countries.de as country_name,
|
||||||
|
countries.code as country_code,
|
||||||
|
SUM(shopping_orders.subtotal_ws) as total_net,
|
||||||
|
SUM(shopping_orders.tax) as total_tax,
|
||||||
|
SUM(shopping_orders.total_shipping) as total_gross
|
||||||
|
')
|
||||||
|
->where('user_invoices.year', $year)
|
||||||
|
->where('user_invoices.cancellation', false)
|
||||||
|
->groupBy('user_invoices.month', 'countries.id', 'countries.de', 'countries.code')
|
||||||
|
->orderBy('user_invoices.month')
|
||||||
|
->orderByDesc('total_gross')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCreditByCountryYear(int $year)
|
||||||
|
{
|
||||||
|
return UserCredit::join('users', 'user_credits.user_id', '=', 'users.id')
|
||||||
|
->join('user_accounts', 'users.account_id', '=', 'user_accounts.id')
|
||||||
|
->join('countries', 'user_accounts.country_id', '=', 'countries.id')
|
||||||
|
->selectRaw('
|
||||||
|
countries.id as country_id,
|
||||||
|
countries.de as country_name,
|
||||||
|
countries.code as country_code,
|
||||||
|
SUM(user_credits.net) as total_net,
|
||||||
|
SUM(user_credits.tax) as total_tax,
|
||||||
|
SUM(user_credits.total) as total_gross
|
||||||
|
')
|
||||||
|
->where('user_credits.year', $year)
|
||||||
|
->where('user_credits.cancellation', false)
|
||||||
|
->groupBy('countries.id', 'countries.de', 'countries.code')
|
||||||
|
->orderByDesc('total_gross')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCreditByCountryMonthly(int $year)
|
||||||
|
{
|
||||||
|
return UserCredit::join('users', 'user_credits.user_id', '=', 'users.id')
|
||||||
|
->join('user_accounts', 'users.account_id', '=', 'user_accounts.id')
|
||||||
|
->join('countries', 'user_accounts.country_id', '=', 'countries.id')
|
||||||
|
->selectRaw('
|
||||||
|
user_credits.month,
|
||||||
|
CONCAT(CASE user_credits.month
|
||||||
|
WHEN 1 THEN \'Januar\'
|
||||||
|
WHEN 2 THEN \'Februar\'
|
||||||
|
WHEN 3 THEN \'März\'
|
||||||
|
WHEN 4 THEN \'April\'
|
||||||
|
WHEN 5 THEN \'Mai\'
|
||||||
|
WHEN 6 THEN \'Juni\'
|
||||||
|
WHEN 7 THEN \'Juli\'
|
||||||
|
WHEN 8 THEN \'August\'
|
||||||
|
WHEN 9 THEN \'September\'
|
||||||
|
WHEN 10 THEN \'Oktober\'
|
||||||
|
WHEN 11 THEN \'November\'
|
||||||
|
WHEN 12 THEN \'Dezember\'
|
||||||
|
END, \' \', user_credits.year) as month_label,
|
||||||
|
countries.id as country_id,
|
||||||
|
countries.de as country_name,
|
||||||
|
countries.code as country_code,
|
||||||
|
SUM(user_credits.net) as total_net,
|
||||||
|
SUM(user_credits.tax) as total_tax,
|
||||||
|
SUM(user_credits.total) as total_gross
|
||||||
|
')
|
||||||
|
->where('user_credits.year', $year)
|
||||||
|
->where('user_credits.cancellation', false)
|
||||||
|
->groupBy('user_credits.month', 'countries.id', 'countries.de', 'countries.code')
|
||||||
|
->orderBy('user_credits.month')
|
||||||
|
->orderByDesc('total_gross')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,8 +61,7 @@ class IncentiveController extends Controller
|
||||||
->withRankingActivity()
|
->withRankingActivity()
|
||||||
->with('user', 'user.account')
|
->with('user', 'user.account')
|
||||||
->orderByIncentiveLeaderboard()
|
->orderByIncentiveLeaderboard()
|
||||||
->limit(self::USER_RANKING_DISPLAY_LIMIT)
|
->paginate(100);
|
||||||
->get();
|
|
||||||
|
|
||||||
$participateHasTrackableAbos = false;
|
$participateHasTrackableAbos = false;
|
||||||
if (! $participant?->accepted_terms_at) {
|
if (! $participant?->accepted_terms_at) {
|
||||||
|
|
@ -74,8 +73,8 @@ class IncentiveController extends Controller
|
||||||
'participant' => $participant,
|
'participant' => $participant,
|
||||||
'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null,
|
'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null,
|
||||||
'ranking' => $ranking,
|
'ranking' => $ranking,
|
||||||
'rankingDisplayLimit' => self::USER_RANKING_DISPLAY_LIMIT,
|
|
||||||
'participateHasTrackableAbos' => $participateHasTrackableAbos,
|
'participateHasTrackableAbos' => $participateHasTrackableAbos,
|
||||||
|
'isVipView' => $user->isVIP(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Models\ShoppingPayment;
|
||||||
use App\Models\ShoppingUser;
|
use App\Models\ShoppingUser;
|
||||||
use App\Repositories\CheckoutRepository;
|
use App\Repositories\CheckoutRepository;
|
||||||
use App\Services\AboHelper;
|
use App\Services\AboHelper;
|
||||||
|
use App\Services\CheckoutFunnelTracker;
|
||||||
use App\Services\CustomerPriority;
|
use App\Services\CustomerPriority;
|
||||||
use App\Services\Payment;
|
use App\Services\Payment;
|
||||||
use App\Services\Shop;
|
use App\Services\Shop;
|
||||||
|
|
@ -98,6 +99,11 @@ class CheckoutController extends Controller
|
||||||
'yard_instance' => $this->instance,
|
'yard_instance' => $this->instance,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::visitedCheckout(
|
||||||
|
consultantUserId: Util::getUserShop()?->id ?? null,
|
||||||
|
metadata: ['is_from' => $is_from, 'is_for' => $is_for, 'is_abo' => $is_abo],
|
||||||
|
);
|
||||||
|
|
||||||
return view('web.templates.checkout', $data);
|
return view('web.templates.checkout', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,6 +174,14 @@ class CheckoutController extends Controller
|
||||||
|
|
||||||
Util::setUserHistoryValue(['status' => 2, 'shopping_order_id' => $shopping_order->id]);
|
Util::setUserHistoryValue(['status' => 2, 'shopping_order_id' => $shopping_order->id]);
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::submittedForm(
|
||||||
|
shoppingUserId: $shopping_user->id,
|
||||||
|
shoppingOrderId: $shopping_order->id,
|
||||||
|
consultantUserId: $shopping_user->auth_user_id ?? null,
|
||||||
|
paymentMethod: Request::get('payment_method'),
|
||||||
|
amountCents: (int) (Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100),
|
||||||
|
);
|
||||||
|
|
||||||
// Zahlungsmethode verarbeiten
|
// Zahlungsmethode verarbeiten
|
||||||
if (Request::get('payment_method')) {
|
if (Request::get('payment_method')) {
|
||||||
return $this->processPaymentMethod($data, $shopping_user, $shopping_order);
|
return $this->processPaymentMethod($data, $shopping_user, $shopping_order);
|
||||||
|
|
@ -400,6 +414,11 @@ class CheckoutController extends Controller
|
||||||
$ShoppingPayment->status = $status;
|
$ShoppingPayment->status = $status;
|
||||||
$ShoppingPayment->save();
|
$ShoppingPayment->save();
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::returnedFromPayment(
|
||||||
|
shoppingPaymentId: $ShoppingPayment->id,
|
||||||
|
returnStatus: $status,
|
||||||
|
);
|
||||||
|
|
||||||
if ($status === 'success') {
|
if ($status === 'success') {
|
||||||
return $this->handleSuccessfulTransaction($ShoppingPayment, $reference);
|
return $this->handleSuccessfulTransaction($ShoppingPayment, $reference);
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +429,9 @@ class CheckoutController extends Controller
|
||||||
|
|
||||||
return $this->showTransactionError(
|
return $this->showTransactionError(
|
||||||
__('payment.payment_canceled'),
|
__('payment.payment_canceled'),
|
||||||
__('payment.payment_canceled_description')
|
__('payment.payment_canceled_description'),
|
||||||
|
'cancel',
|
||||||
|
['checkout_url' => route('checkout.checkout_card')],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,9 +439,29 @@ class CheckoutController extends Controller
|
||||||
Util::setUserHistoryValue(['status' => 23]);
|
Util::setUserHistoryValue(['status' => 23]);
|
||||||
Util::setInstanceStatusByPayment($ShoppingPayment, 5); // link_failed
|
Util::setInstanceStatusByPayment($ShoppingPayment, 5); // link_failed
|
||||||
|
|
||||||
|
$latestTransaction = $ShoppingPayment->payment_transactions()
|
||||||
|
->whereNotNull('errorcode')
|
||||||
|
->orWhere(fn ($q) => $q->whereNotNull('transmitted_data'))
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$errorcode = null;
|
||||||
|
$errorDescription = null;
|
||||||
|
if ($latestTransaction) {
|
||||||
|
$errorcode = $latestTransaction->errorcode
|
||||||
|
?? ($latestTransaction->transmitted_data['errorcode'] ?? null);
|
||||||
|
$errorDescription = $latestTransaction->error_description;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->showTransactionError(
|
return $this->showTransactionError(
|
||||||
__('payment.payment_error'),
|
__('payment.payment_error'),
|
||||||
__('payment.payment_error_description')
|
__('payment.payment_error_description'),
|
||||||
|
'error',
|
||||||
|
[
|
||||||
|
'errorcode' => $errorcode,
|
||||||
|
'error_description' => $errorDescription,
|
||||||
|
'checkout_url' => route('checkout.checkout_card'),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,19 +475,21 @@ class CheckoutController extends Controller
|
||||||
/**
|
/**
|
||||||
* Zeigt eine Transaktionsfehlerseite an
|
* Zeigt eine Transaktionsfehlerseite an
|
||||||
*
|
*
|
||||||
* @param string $title
|
|
||||||
* @param string $message
|
|
||||||
* @return \Illuminate\View\View
|
* @return \Illuminate\View\View
|
||||||
*/
|
*/
|
||||||
private function showTransactionError($title, $message)
|
/**
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
*/
|
||||||
|
private function showTransactionError(string $title, string $message, string $type = 'error', array $extra = [])
|
||||||
{
|
{
|
||||||
$data = [
|
$data = array_merge([
|
||||||
'user_shop' => Util::getUserShop(),
|
'user_shop' => Util::getUserShop(),
|
||||||
'is_checkout' => true,
|
'is_checkout' => true,
|
||||||
'yard_instance' => $this->instance,
|
'yard_instance' => $this->instance,
|
||||||
'error_title' => $title,
|
'error_title' => $title,
|
||||||
'error_message' => $message,
|
'error_message' => $message,
|
||||||
];
|
'error_type' => $type,
|
||||||
|
], $extra);
|
||||||
|
|
||||||
return view('web.templates.checkout-error', $data);
|
return view('web.templates.checkout-error', $data);
|
||||||
}
|
}
|
||||||
|
|
@ -494,6 +537,12 @@ class CheckoutController extends Controller
|
||||||
if ($payt->shopping_payment->reference != $reference) {
|
if ($payt->shopping_payment->reference != $reference) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::confirmedPayment(
|
||||||
|
shoppingPaymentId: $payt->shopping_payment_id,
|
||||||
|
txaction: $payt->txaction ?? $payt->status ?? 'approved',
|
||||||
|
);
|
||||||
|
|
||||||
Yard::instance($this->instance)->destroy();
|
Yard::instance($this->instance)->destroy();
|
||||||
$this->checkoutRepo->sessionDestroy(true);
|
$this->checkoutRepo->sessionDestroy(true);
|
||||||
Util::setInstanceStatus(3, true); // link_pending
|
Util::setInstanceStatus(3, true); // link_pending
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,11 @@ class WizardController extends Controller
|
||||||
if ($cartItem->qty > 1) {
|
if ($cartItem->qty > 1) {
|
||||||
Yard::instance('shopping')->update($cartItem->rowId, 1);
|
Yard::instance('shopping')->update($cartItem->rowId, 1);
|
||||||
}
|
}
|
||||||
|
foreach (Yard::instance('shopping')->content() as $existingItem) {
|
||||||
|
if ($existingItem->rowId !== $cartItem->rowId) {
|
||||||
|
Yard::instance('shopping')->remove($existingItem->rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (\App\Services\UserService::getTaxFree()) {
|
if (\App\Services\UserService::getTaxFree()) {
|
||||||
Yard::setTax($cartItem->rowId, 0);
|
Yard::setTax($cartItem->rowId, 0);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PaymentIncident;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AddIncidentActivityRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => ['required', 'in:note,email,call,ticket,status_change,provider_response'],
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'content' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type.required' => 'Bitte einen Aktivitätstyp auswählen.',
|
||||||
|
'type.in' => 'Ungültiger Aktivitätstyp.',
|
||||||
|
'title.required' => 'Bitte einen Titel angeben.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PaymentIncident;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StorePaymentIncidentRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'notes' => ['nullable', 'string'],
|
||||||
|
'provider' => ['required', 'in:payone,paypal,other'], // stripe,mollie aktuell nicht aktiv
|
||||||
|
'type' => ['required', 'in:outage,ipn_error,payment_failure,slow_response,other'],
|
||||||
|
'severity' => ['required', 'in:low,medium,high,critical'],
|
||||||
|
'affected_orders' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'affected_revenue' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'ticket_number' => ['nullable', 'string', 'max:100'],
|
||||||
|
'detected_at' => ['required', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title.required' => 'Bitte einen Titel angeben.',
|
||||||
|
'provider.required' => 'Bitte einen Anbieter auswählen.',
|
||||||
|
'provider.in' => 'Ungültiger Anbieter.',
|
||||||
|
'type.required' => 'Bitte einen Incident-Typ auswählen.',
|
||||||
|
'type.in' => 'Ungültiger Incident-Typ.',
|
||||||
|
'severity.required' => 'Bitte eine Schwere angeben.',
|
||||||
|
'severity.in' => 'Ungültige Schwere.',
|
||||||
|
'detected_at.required' => 'Bitte ein Erkennungsdatum angeben.',
|
||||||
|
'detected_at.date' => 'Ungültiges Datum.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PaymentIncident;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateIncidentStatusRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['required', 'in:open,in_progress,waiting_provider,resolved,closed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status.required' => 'Bitte einen Status auswählen.',
|
||||||
|
'status.in' => 'Ungültiger Status.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Mail/PaymentIncidentAlert.php
Normal file
31
app/Mail/PaymentIncidentAlert.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PaymentIncidentAlert extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public PaymentIncident $incident) {}
|
||||||
|
|
||||||
|
public function build(): self
|
||||||
|
{
|
||||||
|
$severityLabel = strtoupper($this->incident->severity_label);
|
||||||
|
$subject = "[{$severityLabel}] Payment Incident: {$this->incident->title}";
|
||||||
|
|
||||||
|
return $this
|
||||||
|
->subject($subject)
|
||||||
|
->view('emails.payment-incident-alert')
|
||||||
|
->with([
|
||||||
|
'title' => "Payment Incident – {$severityLabel}",
|
||||||
|
'incident' => $this->incident,
|
||||||
|
'dashboardUrl' => route('admin.payment-dashboard.show', $this->incident),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Models/CheckoutFunnelEvent.php
Normal file
123
app/Models/CheckoutFunnelEvent.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $event checkout_visited|form_submitted|payment_initiated|payment_returned|payment_confirmed
|
||||||
|
* @property string|null $session_id
|
||||||
|
* @property string|null $domain
|
||||||
|
* @property int|null $shopping_user_id
|
||||||
|
* @property int|null $shopping_order_id
|
||||||
|
* @property int|null $shopping_payment_id
|
||||||
|
* @property int|null $consultant_user_id
|
||||||
|
* @property string|null $payment_method
|
||||||
|
* @property string|null $return_status
|
||||||
|
* @property int|null $amount_cents
|
||||||
|
* @property array|null $metadata
|
||||||
|
* @property \Illuminate\Support\Carbon $created_at
|
||||||
|
*/
|
||||||
|
class CheckoutFunnelEvent extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'event',
|
||||||
|
'session_id',
|
||||||
|
'domain',
|
||||||
|
'shopping_user_id',
|
||||||
|
'shopping_order_id',
|
||||||
|
'shopping_payment_id',
|
||||||
|
'consultant_user_id',
|
||||||
|
'payment_method',
|
||||||
|
'return_status',
|
||||||
|
'amount_cents',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'amount_cents' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shopping_order(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ShoppingOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shopping_payment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ShoppingPayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consultant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'consultant_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public static function eventLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'checkout_visited' => 'Checkout aufgerufen',
|
||||||
|
'form_submitted' => 'Formular abgeschickt',
|
||||||
|
'payment_initiated' => 'Zahlung gestartet',
|
||||||
|
'payment_returned' => 'Zurück von PAYONE',
|
||||||
|
'payment_confirmed' => 'PAYONE Callback',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEventLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return self::eventLabels()[$this->event] ?? $this->event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies the domain into a human-readable source channel.
|
||||||
|
*
|
||||||
|
* - my.mivita.* → Salescenter (Berater-Bereich)
|
||||||
|
* - in.mivita.* → Beraterzugang (in.mivita.care login)
|
||||||
|
* - *.mivita.care → Kundenshop (Live)
|
||||||
|
* - *.mivita.shop → Kundenshop (Live)
|
||||||
|
* - *.mivita.test → Testserver
|
||||||
|
*/
|
||||||
|
public function getSourceTypeAttribute(): string
|
||||||
|
{
|
||||||
|
$domain = strtolower($this->domain ?? '');
|
||||||
|
|
||||||
|
if (str_starts_with($domain, 'my.')) {
|
||||||
|
return 'salescenter';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($domain, 'in.')) {
|
||||||
|
return 'beraterzugang';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($domain, '.test') || str_contains($domain, '.mivita.test')) {
|
||||||
|
return 'testserver';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($domain, '.care') || str_ends_with($domain, '.shop')) {
|
||||||
|
return 'kundenshop';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public static function sourceLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'kundenshop' => 'Kundenshop (*.mivita.care / *.mivita.shop)',
|
||||||
|
'salescenter' => 'Salescenter (my.mivita.care)',
|
||||||
|
'beraterzugang' => 'Beraterzugang (in.mivita.care)',
|
||||||
|
'testserver' => 'Testserver (*.mivita.test)',
|
||||||
|
'unbekannt' => 'Unbekannt',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/IncidentActivity.php
Normal file
46
app/Models/IncidentActivity.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class IncidentActivity extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'incident_id',
|
||||||
|
'type',
|
||||||
|
'title',
|
||||||
|
'content',
|
||||||
|
'author',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function incident(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PaymentIncident::class, 'incident_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'email' => 'ion-md-mail',
|
||||||
|
'call' => 'ion-md-call',
|
||||||
|
'ticket' => 'ion-md-pricetag',
|
||||||
|
'status_change' => 'ion-md-swap',
|
||||||
|
'provider_response' => 'ion-md-return-left',
|
||||||
|
default => 'ion-md-create',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'email' => 'E-Mail',
|
||||||
|
'call' => 'Telefonat',
|
||||||
|
'ticket' => 'Ticket',
|
||||||
|
'status_change' => 'Statusänderung',
|
||||||
|
'provider_response' => 'Anbieter-Antwort',
|
||||||
|
default => 'Notiz',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
156
app/Models/PaymentIncident.php
Normal file
156
app/Models/PaymentIncident.php
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PaymentIncident extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'notes',
|
||||||
|
'provider',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'severity',
|
||||||
|
'affected_orders',
|
||||||
|
'affected_revenue',
|
||||||
|
'detected_at',
|
||||||
|
'resolved_at',
|
||||||
|
'ticket_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
protected $attributes = [
|
||||||
|
'status' => 'open',
|
||||||
|
'severity' => 'medium',
|
||||||
|
'provider' => 'payone',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'detected_at' => 'datetime',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
'affected_revenue' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activities(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(IncidentActivity::class, 'incident_id')->orderBy('created_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOpen(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', ['open', 'in_progress', 'waiting_provider']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePayone(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('provider', 'payone');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLastDays(Builder $query, int $days): Builder
|
||||||
|
{
|
||||||
|
return $query->where('detected_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDurationAttribute(): string
|
||||||
|
{
|
||||||
|
$end = $this->resolved_at ?? now();
|
||||||
|
$diff = $this->detected_at->diff($end);
|
||||||
|
|
||||||
|
if ($diff->days > 0) {
|
||||||
|
return $diff->days.'d '.$diff->h.'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($diff->h > 0) {
|
||||||
|
return $diff->h.'h '.$diff->i.'min';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diff->i.' min';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverityColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => 'danger',
|
||||||
|
'high' => 'warning',
|
||||||
|
'medium' => 'info',
|
||||||
|
'low' => 'success',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverityLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => 'Kritisch',
|
||||||
|
'high' => 'Hoch',
|
||||||
|
'medium' => 'Mittel',
|
||||||
|
'low' => 'Niedrig',
|
||||||
|
default => $this->severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
'open' => 'Offen',
|
||||||
|
'in_progress' => 'In Bearbeitung',
|
||||||
|
'waiting_provider' => 'Wartet auf Anbieter',
|
||||||
|
'resolved' => 'Gelöst',
|
||||||
|
'closed' => 'Geschlossen',
|
||||||
|
default => $this->status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
'open' => 'danger',
|
||||||
|
'in_progress' => 'warning',
|
||||||
|
'waiting_provider' => 'info',
|
||||||
|
'resolved' => 'success',
|
||||||
|
'closed' => 'secondary',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'outage' => 'ion-md-power',
|
||||||
|
'ipn_error' => 'ion-md-git-network',
|
||||||
|
'payment_failure' => 'ion-md-card',
|
||||||
|
'slow_response' => 'ion-md-timer',
|
||||||
|
default => 'ion-md-alert',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'outage' => 'Ausfall',
|
||||||
|
'ipn_error' => 'IPN-Fehler',
|
||||||
|
'payment_failure' => 'Zahlungsfehler',
|
||||||
|
'slow_response' => 'Langsame Antwort',
|
||||||
|
default => 'Sonstiges',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->provider) {
|
||||||
|
'payone' => 'PAYONE',
|
||||||
|
// 'stripe' => 'Stripe', // aktuell nicht aktiv
|
||||||
|
'paypal' => 'PayPal',
|
||||||
|
// 'mollie' => 'Mollie', // aktuell nicht aktiv
|
||||||
|
default => 'Sonstige',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\ShoppingPayment $shopping_payment
|
* @property-read \App\Models\ShoppingPayment $shopping_payment
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction query()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction query()
|
||||||
|
|
@ -39,17 +40,19 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereTxid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereTxid($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUpdatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereUserid($value)
|
||||||
|
*
|
||||||
* @property string|null $mode
|
* @property string|null $mode
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction whereMode($value)
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class PaymentTransaction extends Model
|
class PaymentTransaction extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'payment_transactions';
|
protected $table = 'payment_transactions';
|
||||||
|
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'transmitted_data' => 'array'
|
'transmitted_data' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -67,9 +70,52 @@ class PaymentTransaction extends Model
|
||||||
'mode',
|
'mode',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
public function shopping_payment()
|
public function shopping_payment()
|
||||||
{
|
{
|
||||||
return $this->belongsTo('App\Models\ShoppingPayment', 'shopping_payment_id');
|
return $this->belongsTo('App\Models\ShoppingPayment', 'shopping_payment_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable description for known PAYONE error codes.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function payoneErrorDescriptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
2 => 'Allgemeiner Fehler',
|
||||||
|
4 => 'Karte gesperrt',
|
||||||
|
5 => 'Zahlung abgelehnt',
|
||||||
|
12 => 'Ungültige Kartennummer',
|
||||||
|
14 => 'Ungültige Kartendaten',
|
||||||
|
33 => 'Karte abgelaufen',
|
||||||
|
34 => 'Karte gesperrt/gestohlen',
|
||||||
|
55 => 'Falsche PIN',
|
||||||
|
56 => 'Kunde hat abgebrochen',
|
||||||
|
58 => 'Transaktion nicht erlaubt',
|
||||||
|
62 => 'Karte für internationale Transaktionen nicht zugelassen',
|
||||||
|
104 => 'Karte für Online-Zahlungen nicht erlaubt',
|
||||||
|
105 => 'Karte ungültig',
|
||||||
|
120 => 'Falsche Prüfziffer (CVV)',
|
||||||
|
130 => 'Limit überschritten',
|
||||||
|
131 => 'Währung nicht unterstützt',
|
||||||
|
135 => 'Bank antwortet nicht',
|
||||||
|
136 => 'Bank nicht gefunden',
|
||||||
|
137 => 'Betrag stimmt nicht überein',
|
||||||
|
900 => '3D-Secure Authentifizierung fehlgeschlagen',
|
||||||
|
902 => 'Bank hat abgelehnt',
|
||||||
|
970 => 'Keine Antwort (Timeout)',
|
||||||
|
4218 => 'Karte unter Betrugsverdacht',
|
||||||
|
4219 => 'Transaktion von Kartenaussteller abgelehnt',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getErrorDescriptionAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->errorcode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::payoneErrorDescriptions()[(int) $this->errorcode] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/Models/ProviderUptimeLog.php
Normal file
24
app/Models/ProviderUptimeLog.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProviderUptimeLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'provider',
|
||||||
|
'is_up',
|
||||||
|
'response_time_ms',
|
||||||
|
'error_message',
|
||||||
|
'checked_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_up' => 'boolean',
|
||||||
|
'checked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\Paginator;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +17,8 @@ class AppServiceProvider extends ServiceProvider
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
Schema::defaultStringLength(191);
|
Schema::defaultStringLength(191);
|
||||||
|
Paginator::defaultView('pagination::bootstrap-4');
|
||||||
|
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');
|
||||||
|
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
|
|
|
||||||
128
app/Services/CheckoutFunnelTracker.php
Normal file
128
app/Services/CheckoutFunnelTracker.php
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\CheckoutFunnelEvent;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Request;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks checkout funnel events for internal analytics.
|
||||||
|
*
|
||||||
|
* All public methods are fire-and-forget: exceptions are caught and logged
|
||||||
|
* so that a tracking failure never breaks the checkout flow.
|
||||||
|
*/
|
||||||
|
class CheckoutFunnelTracker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Step 1 – User opens the checkout page.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function visitedCheckout(?int $consultantUserId = null, array $metadata = []): void
|
||||||
|
{
|
||||||
|
self::record('checkout_visited', [
|
||||||
|
'consultant_user_id' => $consultantUserId,
|
||||||
|
'metadata' => $metadata ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 – User submits the checkout form successfully (ShoppingOrder created).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function submittedForm(
|
||||||
|
int $shoppingUserId,
|
||||||
|
int $shoppingOrderId,
|
||||||
|
?int $consultantUserId,
|
||||||
|
?string $paymentMethod,
|
||||||
|
?int $amountCents,
|
||||||
|
array $metadata = [],
|
||||||
|
): void {
|
||||||
|
self::record('form_submitted', [
|
||||||
|
'shopping_user_id' => $shoppingUserId,
|
||||||
|
'shopping_order_id' => $shoppingOrderId,
|
||||||
|
'consultant_user_id' => $consultantUserId,
|
||||||
|
'payment_method' => $paymentMethod,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
'metadata' => $metadata ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 – User is redirected to PAYONE (ShoppingPayment created).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function initiatedPayment(
|
||||||
|
int $shoppingUserId,
|
||||||
|
int $shoppingOrderId,
|
||||||
|
int $shoppingPaymentId,
|
||||||
|
?int $consultantUserId,
|
||||||
|
?string $paymentMethod,
|
||||||
|
?int $amountCents,
|
||||||
|
array $metadata = [],
|
||||||
|
): void {
|
||||||
|
self::record('payment_initiated', [
|
||||||
|
'shopping_user_id' => $shoppingUserId,
|
||||||
|
'shopping_order_id' => $shoppingOrderId,
|
||||||
|
'shopping_payment_id' => $shoppingPaymentId,
|
||||||
|
'consultant_user_id' => $consultantUserId,
|
||||||
|
'payment_method' => $paymentMethod,
|
||||||
|
'amount_cents' => $amountCents,
|
||||||
|
'metadata' => $metadata ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4 – User returns from PAYONE redirect (success / cancel / error).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function returnedFromPayment(
|
||||||
|
int $shoppingPaymentId,
|
||||||
|
string $returnStatus,
|
||||||
|
array $metadata = [],
|
||||||
|
): void {
|
||||||
|
self::record('payment_returned', [
|
||||||
|
'shopping_payment_id' => $shoppingPaymentId,
|
||||||
|
'return_status' => $returnStatus,
|
||||||
|
'metadata' => $metadata ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 5 – PAYONE IPN callback received (PaymentTransaction created).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public static function confirmedPayment(
|
||||||
|
int $shoppingPaymentId,
|
||||||
|
string $txaction,
|
||||||
|
array $metadata = [],
|
||||||
|
): void {
|
||||||
|
self::record('payment_confirmed', [
|
||||||
|
'shopping_payment_id' => $shoppingPaymentId,
|
||||||
|
'metadata' => array_merge(['txaction' => $txaction], $metadata) ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attributes */
|
||||||
|
private static function record(string $event, array $attributes): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
CheckoutFunnelEvent::create(array_merge([
|
||||||
|
'event' => $event,
|
||||||
|
'session_id' => Session::getId(),
|
||||||
|
'domain' => Request::getHost(),
|
||||||
|
], $attributes));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('CheckoutFunnelTracker: could not record event', [
|
||||||
|
'event' => $event,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('payment_incidents', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie', 'other'])->default('payone');
|
||||||
|
$table->enum('type', ['outage', 'ipn_error', 'payment_failure', 'slow_response', 'other'])->default('other');
|
||||||
|
$table->enum('status', ['open', 'in_progress', 'waiting_provider', 'resolved', 'closed'])->default('open');
|
||||||
|
$table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium');
|
||||||
|
$table->integer('affected_orders')->default(0);
|
||||||
|
$table->decimal('affected_revenue', 10, 2)->default(0);
|
||||||
|
$table->timestamp('detected_at');
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->string('ticket_number')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['provider', 'detected_at']);
|
||||||
|
$table->index('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('incident_activities', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('incident_id')->constrained('payment_incidents')->onDelete('cascade');
|
||||||
|
$table->enum('type', ['note', 'email', 'call', 'ticket', 'status_change', 'provider_response']);
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('content')->nullable();
|
||||||
|
$table->string('author')->default('System');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('incident_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('provider_uptime_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie']);
|
||||||
|
$table->boolean('is_up')->default(true);
|
||||||
|
$table->integer('response_time_ms')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->timestamp('checked_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['provider', 'checked_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('provider_uptime_logs');
|
||||||
|
Schema::dropIfExists('incident_activities');
|
||||||
|
Schema::dropIfExists('payment_incidents');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('checkout_funnel_events', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->enum('event', [
|
||||||
|
'checkout_visited',
|
||||||
|
'form_submitted',
|
||||||
|
'payment_initiated',
|
||||||
|
'payment_returned',
|
||||||
|
'payment_confirmed',
|
||||||
|
])->index();
|
||||||
|
|
||||||
|
$table->string('session_id', 100)->nullable()->index();
|
||||||
|
$table->string('domain', 200)->nullable();
|
||||||
|
$table->unsignedBigInteger('shopping_user_id')->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('shopping_order_id')->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('shopping_payment_id')->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('consultant_user_id')->nullable()->index();
|
||||||
|
|
||||||
|
$table->string('payment_method', 50)->nullable();
|
||||||
|
$table->string('return_status', 20)->nullable();
|
||||||
|
$table->unsignedInteger('amount_cents')->nullable();
|
||||||
|
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('checkout_funnel_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
503
dev/payment-dashboard/ENTWICKLUNGSPLAN.md
Normal file
503
dev/payment-dashboard/ENTWICKLUNGSPLAN.md
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
# Payment Dashboard – Entwicklungsplan
|
||||||
|
|
||||||
|
**Ziel:** Integration eines Payment-Monitoring-Dashboards in mivita.care, das PAYONE-Probleme
|
||||||
|
frühzeitig sichtbar macht. Das Dashboard kombiniert manuell erfasste Incidents mit echten Daten
|
||||||
|
aus den bestehenden Tabellen (`payment_transactions`, `shopping_payments`) und den vorhandenen
|
||||||
|
PAYONE-Logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungsstand (Stand: 13.04.2026)
|
||||||
|
|
||||||
|
| Phase | Thema | Status | Anmerkungen |
|
||||||
|
|-------|------------------------------|-------------------|-------------|
|
||||||
|
| 1 | Datenbank & Models | ✅ Umgesetzt | Migration, alle drei Models vorhanden |
|
||||||
|
| 2 | Controller & Routes | ✅ Umgesetzt | Routes in `routes/domains/crm.php`, Entwurfsdatei unter `dev/` ist veraltet |
|
||||||
|
| 3 | Views (Bootstrap/Appwork) | ✅ Umgesetzt | Index, Management, Show, Transactions, Logs + 4 Partials |
|
||||||
|
| 4 | Live-Transaktionsdaten | ✅ Umgesetzt | `transactions()` + View mit Filtern, Statistiken, Pagination |
|
||||||
|
| 5 | Log-Viewer | ✅ Umgesetzt | `?date=`-Parameter wird ausgewertet; sichere Validierung der Datumseingabe |
|
||||||
|
| 6 | Uptime-Check Artisan Command | ✅ Umgesetzt | Command `payment:check-uptime`, Schedule alle 5 Minuten, Uptime-Karten im Dashboard |
|
||||||
|
| 7 | E-Mail-Benachrichtigung | ✅ Umgesetzt | Dediziertes Mailable `PaymentIncidentAlert` (Queue), sendet bei `critical`-Incidents |
|
||||||
|
| 8 | Tests | ✅ Umgesetzt | `PaymentDashboardAccessTest`, `PaymentIncidentCrudTest`, `CheckPaymentUptimeCommandTest` |
|
||||||
|
|
||||||
|
### Zusätzlich umgesetzt (nicht in Phasen geplant)
|
||||||
|
|
||||||
|
- **Navigation:** Sidenav-Link mit gecachtem Badge für offene Incidents in `layout-sidenav.blade.php`
|
||||||
|
- **Übersetzungen:** `payment_monitor` in `de`, `en`, `es` navigation.php
|
||||||
|
- **Form Requests:** `StorePaymentIncidentRequest` + `AddIncidentActivityRequest` in `app/Http/Requests/PaymentIncident/`
|
||||||
|
- **Cache-Invalidierung:** `Cache::forget('open_incident_count')` in `store()`, `updateStatus()`, `addActivity()`
|
||||||
|
|
||||||
|
### Bekannte Abweichungen vom Plan
|
||||||
|
|
||||||
|
- **Views:** Nutzen `layouts.layout-2` statt `layout-1` (wie im Plan angegeben)
|
||||||
|
- **Entwurfsdatei** `dev/payment-dashboard/routes/payment-dashboard.php`: Veraltet — falscher Namespace (`App\Http\Controllers\PaymentDashboardController`), falsche Methodennamen (`developer` statt `index`), nur `auth`-Middleware statt `admin`
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
1. **Phase 9 (Auto-Incident aus `PayoneController`):** Noch nicht begonnen — kritische Fehler (z.B. Error:2008) könnten automatisch Incidents anlegen
|
||||||
|
2. **Entwurfsdatei aufräumen:** `dev/payment-dashboard/routes/payment-dashboard.php` ggf. löschen oder als Archiv kennzeichnen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse des Ist-Zustands
|
||||||
|
|
||||||
|
### Bereits vorhandene Datenquellen
|
||||||
|
|
||||||
|
| Quelle | Relevanz |
|
||||||
|
|-------------------------------|-------------------------------------------------------------------|
|
||||||
|
| `payment_transactions` | Jeder PAYONE-Callback: `txaction`, `errorcode`, `errormessage`, `mode`, `transmitted_data` |
|
||||||
|
| `shopping_payments` | Jede Zahlung: `reference`, `txaction`, `clearingtype`, `amount` |
|
||||||
|
| `shopping_orders` | Bestellstatus, `paid`-Flag, Verknüpfung zu Payments |
|
||||||
|
| `storage/logs/payone.log` | Fehler-Details aus `MyLog::writeLog('payone', ...)` (Error 2001–2008) |
|
||||||
|
| `storage/logs/payment.log` | Allgemeine Zahlungsfehler |
|
||||||
|
|
||||||
|
### Bestehende PAYONE-Fehlercodes (aus `Api/PayoneController`)
|
||||||
|
|
||||||
|
| Code | Bedeutung |
|
||||||
|
|--------|------------------------------------------------|
|
||||||
|
| 2001 | Callback: Parameter unvollständig |
|
||||||
|
| 2002 | Callback: Key-Validierung fehlgeschlagen |
|
||||||
|
| 2003 | Callback: ShoppingOrder nicht gefunden |
|
||||||
|
| 2004 | Callback: ShoppingPayment nicht gefunden |
|
||||||
|
| 2005 | Callback: Payment ↔ Order Zuordnung falsch |
|
||||||
|
| 2006 | Callback: Preisabweichung |
|
||||||
|
| 2008 | Callback: Datenbank-Transaktion fehlgeschlagen |
|
||||||
|
|
||||||
|
### Wichtige Unterschiede zum Entwurf
|
||||||
|
|
||||||
|
- **Layout:** Das Dashboard nutzt das bestehende Appwork/Bootstrap-Layout (`layout-1.blade.php`),
|
||||||
|
nicht das Custom-Dark-Mode-Layout aus dem Entwurf.
|
||||||
|
- **Auth:** Middleware `admin` (admin >= 2) statt nur `auth` — kein separates Rollen-System nötig.
|
||||||
|
- **Echte Daten:** Statt rein manuelle Incidents, werden bestehende Transaktionsdaten direkt
|
||||||
|
eingebunden (Live-Tab). Manuelle Incidents bleiben als Eskalationswerkzeug erhalten.
|
||||||
|
- **Logs:** Der `payone`-Log-Kanal wird direkt im Dashboard lesbar gemacht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasen-Übersicht
|
||||||
|
|
||||||
|
| Phase | Titel | Aufwand | Priorität |
|
||||||
|
|-------|------------------------------|----------|-----------|
|
||||||
|
| 1 | Datenbank & Models | ~1h | Muss |
|
||||||
|
| 2 | Controller & Routes | ~2h | Muss |
|
||||||
|
| 3 | Views (Bootstrap/Appwork) | ~3h | Muss |
|
||||||
|
| 4 | Live-Daten: Transaktionen | ~2h | Hoch |
|
||||||
|
| 5 | Log-Viewer | ~1h | Hoch |
|
||||||
|
| 6 | Uptime-Check Artisan Command | ~2h | Mittel |
|
||||||
|
| 7 | E-Mail-Benachrichtigung | ~1h | Mittel |
|
||||||
|
| 8 | Tests | ~2h | Muss |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Datenbank & Models ✅
|
||||||
|
|
||||||
|
### Migration (aus dem Entwurf übernehmen, leicht angepasst)
|
||||||
|
|
||||||
|
**Datei:** `database/migrations/YYYY_MM_DD_000001_create_payment_incidents_table.php`
|
||||||
|
|
||||||
|
Drei neue Tabellen:
|
||||||
|
- `payment_incidents` — manuell erfasste Störungen/Incidents
|
||||||
|
- `incident_activities` — Kommunikationsverlauf pro Incident (Notizen, Tickets, Anrufe)
|
||||||
|
- `provider_uptime_logs` — automatische Uptime-Checks (Phase 6)
|
||||||
|
|
||||||
|
Anpassungen zum Entwurf:
|
||||||
|
- `provider`-Enum zunächst auf `payone` fokussiert (andere Provider ergänzen, wenn aktiv genutzt)
|
||||||
|
- `notes`-Feld ergänzen in `payment_incidents` für freie interne Kommentare
|
||||||
|
- Index auf `detected_at` und `provider` für Performance bei größeren Datenmengen
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
**`app/Models/PaymentIncident.php`** — aus dem Entwurf übernehmen, ergänzen um:
|
||||||
|
- `scopeOpen()` / `scopePayone()` / `scopeLastDays(int $days)` für häufige Abfragen
|
||||||
|
- `getTypeIconAttribute()` für die View-Darstellung
|
||||||
|
|
||||||
|
**`app/Models/IncidentActivity.php`** — direkt aus dem Entwurf übernehmen
|
||||||
|
|
||||||
|
**`app/Models/ProviderUptimeLog.php`** — neu erstellen für Phase 6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Controller & Routes ✅
|
||||||
|
|
||||||
|
### Controller-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Http/Controllers/Admin/PaymentDashboardController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Platzierung unter `Admin/`, konsistent mit `PaymentSalesController.php`.
|
||||||
|
|
||||||
|
#### Actions
|
||||||
|
|
||||||
|
| Method | URL | Beschreibung |
|
||||||
|
|-----------|-----------------------------------------|--------------------------------------------|
|
||||||
|
| `index` | GET `/admin/payment-dashboard` | Entwickler-Ansicht: alle Incidents + Stats |
|
||||||
|
| `management` | GET `/admin/payment-dashboard/management` | GF-Ansicht: Ampel-Karten, kein Bearbeiten |
|
||||||
|
| `show` | GET `/admin/payment-dashboard/{incident}` | Incident-Detail mit Timeline |
|
||||||
|
| `store` | POST `/admin/payment-dashboard` | Neuen Incident anlegen |
|
||||||
|
| `addActivity` | POST `/admin/payment-dashboard/{incident}/activity` | Aktivität hinzufügen |
|
||||||
|
| `updateStatus` | PATCH `/admin/payment-dashboard/{incident}/status` | Status ändern |
|
||||||
|
| `transactions` | GET `/admin/payment-dashboard/transactions` | Live-PAYONE-Transaktionen (Phase 4) |
|
||||||
|
| `logs` | GET `/admin/payment-dashboard/logs` | PAYONE-Log-Viewer (Phase 5) |
|
||||||
|
|
||||||
|
#### Stats-Methoden im Controller
|
||||||
|
|
||||||
|
Zusätzlich zu den Stats aus dem Entwurf:
|
||||||
|
- `getPayoneTransactionStats()` — Fehlerquote der letzten 7/30 Tage aus `payment_transactions`
|
||||||
|
- `getFailedPayments()` — alle Payments mit `txaction = 'failed'` der letzten 30 Tage
|
||||||
|
- `getErrorDistribution()` — Häufigkeit der Fehlercodes 2001–2008 aus den Logs
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
In `routes/web.php` ergänzen (nicht als separates File, analog zu bestehender Struktur):
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::prefix('admin/payment-dashboard')
|
||||||
|
->name('admin.payment-dashboard.')
|
||||||
|
->middleware(['auth', 'admin'])
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/', [PaymentDashboardController::class, 'index'])->name('index');
|
||||||
|
Route::get('/management', [PaymentDashboardController::class, 'management'])->name('management');
|
||||||
|
Route::get('/transactions', [PaymentDashboardController::class, 'transactions'])->name('transactions');
|
||||||
|
Route::get('/logs', [PaymentDashboardController::class, 'logs'])->name('logs');
|
||||||
|
Route::get('/{incident}', [PaymentDashboardController::class, 'show'])->name('show');
|
||||||
|
Route::post('/', [PaymentDashboardController::class, 'store'])->name('store');
|
||||||
|
Route::post('/{incident}/activity', [PaymentDashboardController::class, 'addActivity'])->name('activity.store');
|
||||||
|
Route::patch('/{incident}/status', [PaymentDashboardController::class, 'updateStatus'])->name('status.update');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Views (Bootstrap/Appwork) ✅
|
||||||
|
|
||||||
|
### Layout-Anpassung
|
||||||
|
|
||||||
|
**Kein** Custom-Dark-Mode-Layout. Stattdessen: `@extends('layouts.layout-1')`, analog zu
|
||||||
|
anderen Admin-Views (z.B. `resources/views/admin/payment/salesvolume.blade.php`).
|
||||||
|
|
||||||
|
### View-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/views/admin/payment-dashboard/
|
||||||
|
├── index.blade.php # Entwickler-Ansicht
|
||||||
|
├── management.blade.php # GF-Ansicht
|
||||||
|
├── show.blade.php # Incident-Detail
|
||||||
|
├── transactions.blade.php # Live-Transaktionen (Phase 4)
|
||||||
|
├── logs.blade.php # Log-Viewer (Phase 5)
|
||||||
|
└── _partials/
|
||||||
|
├── stats-cards.blade.php
|
||||||
|
├── incident-table.blade.php
|
||||||
|
├── activity-timeline.blade.php
|
||||||
|
└── create-incident-modal.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
|
||||||
|
**Entwickler-Ansicht (`index.blade.php`):**
|
||||||
|
- Stat-Karten oben: Offene Incidents | In Bearbeitung | PAYONE-Fehler (30 Tage) | Betroffener Umsatz
|
||||||
|
- Tabs: "Incidents" | "Live-Transaktionen" | "PAYONE Logs"
|
||||||
|
- Tabelle: Alle Incidents mit Status-Badge, Schwere-Farbe, Dauer, Quick-Status-Update
|
||||||
|
- Floating Button: "Neuen Incident anlegen" → Bootstrap-Modal
|
||||||
|
|
||||||
|
**GF-Ansicht (`management.blade.php`):**
|
||||||
|
- Ampel-Karten: Grün/Gelb/Rot je nach offenen Incidents und Schwere
|
||||||
|
- Sehr einfach, keine Bearbeitungsfunktionen
|
||||||
|
- Tagesaktuelle Zusammenfassung
|
||||||
|
|
||||||
|
**Incident-Detail (`show.blade.php`):**
|
||||||
|
- Kopfbereich: Titel, Provider-Badge, Status-Badge, Severity-Indikator
|
||||||
|
- Timeline: Chronologische Aktivitätsliste mit Icon je Typ
|
||||||
|
- Formulare: Aktivität hinzufügen, Status ändern
|
||||||
|
- Link zur betroffenen Bestellung (falls `ticket_number` eine Bestell-ID ist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Live-Transaktionsdaten ✅
|
||||||
|
|
||||||
|
Das wertvollste Feature: Echtzeit-Einblick in PAYONE-Transaktionen aus bestehenden Tabellen.
|
||||||
|
|
||||||
|
### Tab "Live-Transaktionen" in der Entwickler-Ansicht
|
||||||
|
|
||||||
|
Datenquelle: `payment_transactions` JOIN `shopping_payments` JOIN `shopping_orders`
|
||||||
|
|
||||||
|
**Anzeigen:**
|
||||||
|
- Alle Transaktionen der letzten 7 Tage, gefiltert nach `txaction`
|
||||||
|
- Fehlgeschlagene Transaktionen (`txaction = 'failed'`) hervorgehoben in Rot
|
||||||
|
- Fehlercodes und Fehlermeldungen aus `errorcode` / `errormessage` / `customermessage`
|
||||||
|
- `mode`-Feld: unterscheidet Test vs. Live-Modus (wichtig für Debugging)
|
||||||
|
- `transmitted_data` JSON: aufklappbar für Detailinspektion
|
||||||
|
|
||||||
|
**Filter-Optionen:**
|
||||||
|
- Nach `txaction`: `appointed`, `pending`, `paid`, `failed`
|
||||||
|
- Nach Zeitraum: Heute / Letzte 7 Tage / Letzte 30 Tage
|
||||||
|
- Nach Modus: Test / Live
|
||||||
|
|
||||||
|
**Stat-Block oben:**
|
||||||
|
- Erfolgsrate (paid / gesamt) der letzten 24h
|
||||||
|
- Anzahl `failed` Transaktionen heute
|
||||||
|
- Letzte `failed` Transaktion: vor X Minuten
|
||||||
|
- Verteilung der `clearingtype` (wlt, cc, elv…)
|
||||||
|
|
||||||
|
### Neue Methode im Controller
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function getTransactionStats(int $days = 7): array
|
||||||
|
{
|
||||||
|
$since = now()->subDays($days);
|
||||||
|
return [
|
||||||
|
'total' => PaymentTransaction::where('created_at', '>=', $since)->count(),
|
||||||
|
'failed' => PaymentTransaction::where('txaction', 'failed')->where('created_at', '>=', $since)->count(),
|
||||||
|
'paid' => PaymentTransaction::where('txaction', 'paid')->where('created_at', '>=', $since)->count(),
|
||||||
|
'errors' => PaymentTransaction::whereNotNull('errorcode')->where('created_at', '>=', $since)
|
||||||
|
->select('errorcode', 'errormessage', DB::raw('count(*) as count'))
|
||||||
|
->groupBy('errorcode', 'errormessage')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->get(),
|
||||||
|
'last_failed' => PaymentTransaction::where('txaction', 'failed')->latest()->first(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Log-Viewer ✅
|
||||||
|
|
||||||
|
### Tab "PAYONE Logs" in der Entwickler-Ansicht
|
||||||
|
|
||||||
|
Liest direkt aus `storage/logs/payone.log` (aktuellste Datei bei daily rotation).
|
||||||
|
|
||||||
|
**Anzeigen:**
|
||||||
|
- Letzte 100 Log-Einträge (konfigurierbar)
|
||||||
|
- Farbliche Markierung nach Level: `error` (rot), `warning` (gelb), `info` (blau), `notice` (grau)
|
||||||
|
- Suche/Filter nach Fehlercode (z.B. "Error:2003")
|
||||||
|
- Zeitstempel, Log-Level, Nachricht, JSON-Payload (aufklappbar)
|
||||||
|
|
||||||
|
**Implementierung:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function logs(): View
|
||||||
|
{
|
||||||
|
$logPath = storage_path('logs/payone-' . now()->format('Y-m-d') . '.log');
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if (file_exists($logPath)) {
|
||||||
|
$lines = array_reverse(file($logPath));
|
||||||
|
foreach (array_slice($lines, 0, 200) as $line) {
|
||||||
|
if (preg_match('/\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[^\]]*)\] \w+\.(\w+): (.+)/', $line, $m)) {
|
||||||
|
$entries[] = [
|
||||||
|
'timestamp' => $m[1],
|
||||||
|
'level' => $m[2],
|
||||||
|
'message' => $m[3],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.payment-dashboard.logs', compact('entries'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Artisan Command – Uptime-Check ✅
|
||||||
|
|
||||||
|
### `php artisan payment:check-uptime`
|
||||||
|
|
||||||
|
**Datei:** `app/Console/Commands/CheckPaymentUptime.php`
|
||||||
|
|
||||||
|
Prüft erreichbare PAYONE-Endpunkte (Status-API oder bekannte öffentliche URLs) und legt bei
|
||||||
|
Ausfall automatisch einen Incident an.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Prüft: PAYONE Server-Status-Seite oder einen konfigurierbaren Health-Endpoint
|
||||||
|
// Speichert Ergebnis in provider_uptime_logs
|
||||||
|
// Bei Ausfall: erstellt PaymentIncident mit severity = 'critical', type = 'outage'
|
||||||
|
// Bei Wiederherstellung: setzt offene Outage-Incidents auf 'resolved'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scheduling in `app/Console/Kernel.php`:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
$schedule->command('payment:check-uptime')->everyFiveMinutes();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Konfiguration in `.env` / `config/services.php`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PAYONE_HEALTH_CHECK_URL=https://api.pay1.de/post-gateway/
|
||||||
|
PAYONE_HEALTH_CHECK_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: E-Mail-Benachrichtigung ✅
|
||||||
|
|
||||||
|
Bei neu eröffnetem Critical-Incident: automatische Mail via bestehenden `MyLog`-Mechanismus.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In PaymentDashboardController::store()
|
||||||
|
if ($validated['severity'] === 'critical') {
|
||||||
|
MyLog::writeLog(
|
||||||
|
'payment',
|
||||||
|
'error',
|
||||||
|
'Kritischer Zahlungs-Incident eröffnet: ' . $validated['title'],
|
||||||
|
$validated,
|
||||||
|
true // sendet Mail an config('app.exception_mail')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ: Dedizierte Mailable `App\Mail\PaymentIncidentAlert` für bessere Darstellung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Tests ✅ (vollständig)
|
||||||
|
|
||||||
|
**Feature-Tests:**
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/Feature/PaymentDashboard/
|
||||||
|
├── PaymentDashboardAccessTest.php # Auth, Admin-Middleware
|
||||||
|
├── PaymentIncidentCrudTest.php # Create, Status-Update, Aktivität
|
||||||
|
├── PaymentDashboardStatsTest.php # Korrekte Stats aus Testdaten
|
||||||
|
└── CheckPaymentUptimeCommandTest.php # Artisan Command (Phase 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testszenarien:**
|
||||||
|
- Nicht eingeloggter User → Redirect
|
||||||
|
- Eingeloggter User ohne Admin (admin < 2) → 403
|
||||||
|
- Admin (admin >= 2) → Zugriff auf Entwickler-Ansicht
|
||||||
|
- Incident anlegen: Pflichtfelder, korrekte Aktivität wird auto-erstellt
|
||||||
|
- Status auf "resolved" setzen → `resolved_at` wird gesetzt
|
||||||
|
- Stats-Methoden liefern korrekte Werte mit Testdaten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge (empfohlen)
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Migration + Models)
|
||||||
|
→ Phase 2 (Routes + Controller-Grundstruktur)
|
||||||
|
→ Phase 3 (Views mit Bootstrap, ohne Echtdaten)
|
||||||
|
→ Phase 4 (Live-Transaktionsdaten einbauen)
|
||||||
|
→ Phase 5 (Log-Viewer)
|
||||||
|
→ Phase 8 (Tests für Phasen 1–5)
|
||||||
|
→ Phase 6 (Uptime-Check, optional)
|
||||||
|
→ Phase 7 (Benachrichtigung, optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbankzugriff auf bestehende Daten – kein Code-Eingriff nötig
|
||||||
|
|
||||||
|
Phase 4 liest nur lesend aus bestehenden Tabellen. Es sind **keine Änderungen** an:
|
||||||
|
- `Api/PayoneController` (PAYONE-Callback-Handler)
|
||||||
|
- `ShoppingPayment`, `PaymentTransaction` (Models)
|
||||||
|
- bestehenden Migrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation (`layout-sidenav.blade.php`)
|
||||||
|
|
||||||
|
**Datei:** `resources/views/layouts/includes/layout-sidenav.blade.php`
|
||||||
|
|
||||||
|
Der Eintrag kommt als letztes Item im bestehenden `admin/payments`-Untermenü (nach "Steuerberater",
|
||||||
|
aktuell Zeile ~372). **Kein** eigener Top-Level-Eintrag — das Dashboard gehört thematisch zu Payments.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<li class="sidenav-item{{ Request::is('admin/payment-dashboard*') ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="sidenav-link">
|
||||||
|
<i class="sidenav-icon ion ion-md-alert"></i>
|
||||||
|
<div>{{ __('navigation.payment_monitor') }}</div>
|
||||||
|
@php $openIncidentCount = Cache::remember('open_incident_count', 60, fn() =>
|
||||||
|
\App\Models\PaymentIncident::whereIn('status', ['open','in_progress','waiting_provider'])->count()
|
||||||
|
); @endphp
|
||||||
|
@if ($openIncidentCount > 0)
|
||||||
|
<div class="pl-1 ml-auto">
|
||||||
|
<div class="badge badge-danger">{{ $openIncidentCount }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Badge ist gecacht (60 Sekunden) um N+1-Queries bei jedem Seitenaufruf zu vermeiden.
|
||||||
|
Der Cache wird im Controller bei Incident-Änderungen mit `Cache::forget('open_incident_count')` invalidiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Übersetzungsschlüssel
|
||||||
|
|
||||||
|
In alle drei Sprachdateien ergänzen:
|
||||||
|
|
||||||
|
**`resources/lang/de/navigation.php`**
|
||||||
|
```php
|
||||||
|
'payment_monitor' => 'Payment Monitor',
|
||||||
|
```
|
||||||
|
|
||||||
|
**`resources/lang/en/navigation.php`**
|
||||||
|
```php
|
||||||
|
'payment_monitor' => 'Payment Monitor',
|
||||||
|
```
|
||||||
|
|
||||||
|
**`resources/lang/es/navigation.php`**
|
||||||
|
```php
|
||||||
|
'payment_monitor' => 'Monitor de Pagos',
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form Request Klassen
|
||||||
|
|
||||||
|
Laut Projektkonventionen: keine Inline-Validierung im Controller.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Http/Requests/PaymentIncident/
|
||||||
|
├── StorePaymentIncidentRequest.php # Validierung für store()
|
||||||
|
└── AddIncidentActivityRequest.php # Validierung für addActivity()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Incident aus `PayoneController` (Phase 9, optional)
|
||||||
|
|
||||||
|
Der wertvollste Ausbauschritt: Kritische Fehler im PAYONE-Callback-Handler legen automatisch
|
||||||
|
einen Incident an, ohne manuelle Erfassung.
|
||||||
|
|
||||||
|
**Eingriff in `app/Http/Controllers/Api/PayoneController.php`:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Bei Error:2008 (DB-Rollback) → sofort Critical-Incident
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\DB::rollBack();
|
||||||
|
MyLog::writeLog('payone', 'error', 'Error:2008 ...', [...]);
|
||||||
|
|
||||||
|
// NEU: Automatischer Critical-Incident
|
||||||
|
PaymentIncident::firstOrCreate(
|
||||||
|
['type' => 'payment_failure', 'status' => 'open', 'provider' => 'payone',
|
||||||
|
'detected_at' => now()->startOfHour()], // deduplication per Stunde
|
||||||
|
['title' => 'Automatisch: DB-Fehler bei PAYONE-Callback (Error:2008)',
|
||||||
|
'severity' => 'critical', 'ticket_number' => $data['txid'] ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`firstOrCreate` mit Stunden-Deduplication verhindert Duplikate bei mehrfachen Fehlern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Fragen vor Start
|
||||||
|
|
||||||
|
1. **Welcher Admin-User ist "Alois"?** Hat er `admin >= 2`? Falls nicht → eigene schlanke
|
||||||
|
Route ohne `admin`-Middleware, geschützt durch separaten Login oder feste User-ID-Prüfung.
|
||||||
|
unter Superadmin!
|
||||||
|
|
||||||
|
2. **PAYONE Health-Endpoint:** Welche URL soll der Uptime-Check prüfen?
|
||||||
|
(Phase 6 — kann vorerst übersprungen werden)
|
||||||
|
|
||||||
|
3. **Mollie/Stripe/PayPal aktiv?** Falls nein → Provider-Enum im ersten Schritt auf
|
||||||
|
`payone` + `other` reduzieren, spätere Erweiterung bleibt möglich.
|
||||||
|
|
||||||
|
4. **Cache-Invalidierung:** `Cache::forget('open_incident_count')` muss in
|
||||||
|
`store()`, `updateStatus()` und dem Artisan Command aufgerufen werden.
|
||||||
100
dev/payment-dashboard/README.md
Normal file
100
dev/payment-dashboard/README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# mivita Payment Dashboard – Laravel Modul
|
||||||
|
|
||||||
|
Aktivitäts- und Störungs-Dashboard für die Zahlungsanbieter (PAYONE, Stripe, PayPal, Mollie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Dateien kopieren
|
||||||
|
|
||||||
|
Kopiere die Ordner in dein bestehendes Laravel-Projekt:
|
||||||
|
|
||||||
|
```
|
||||||
|
database/migrations/ → in dein database/migrations/
|
||||||
|
app/Models/ → PaymentIncident.php + IncidentActivity.php nach app/Models/
|
||||||
|
app/Http/Controllers/ → PaymentDashboardController.php nach app/Http/Controllers/
|
||||||
|
resources/views/ → Ordner dashboard/ und layouts/dashboard.blade.php
|
||||||
|
routes/ → payment-dashboard.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Migration ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstellt drei Tabellen:
|
||||||
|
- `payment_incidents` – Incidents / Störungen
|
||||||
|
- `incident_activities` – Kommunikationsverlauf pro Incident
|
||||||
|
- `provider_uptime_logs` – Uptime-Logs (für spätere Automatisierung)
|
||||||
|
|
||||||
|
### 3. Routes einbinden
|
||||||
|
|
||||||
|
In `routes/web.php` ergänzen:
|
||||||
|
|
||||||
|
```php
|
||||||
|
require base_path('routes/payment-dashboard.php');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Layout prüfen
|
||||||
|
|
||||||
|
Das Dashboard hat ein eigenes Layout (`layouts/dashboard.blade.php`).
|
||||||
|
Falls du ein bestehendes App-Layout verwenden willst, passe die
|
||||||
|
`@extends`-Direktive in den Views entsprechend an.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| URL | Beschreibung |
|
||||||
|
|----------------------------------|-------------------------------------|
|
||||||
|
| `/payment-dashboard` | Entwickler-Ansicht (Kevin) |
|
||||||
|
| `/payment-dashboard/management` | GF-Ansicht (Alois) – read-only |
|
||||||
|
| `/payment-dashboard/{id}` | Incident-Detail mit Timeline |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Entwickler-Ansicht
|
||||||
|
- Alle offenen Incidents mit Schnell-Status-Update
|
||||||
|
- Neuen Incident anlegen (Modal)
|
||||||
|
- Kommunikations-Timeline (E-Mails, Tickets, Calls, Notizen)
|
||||||
|
- Vollständige Incident-Tabelle mit Pagination
|
||||||
|
- Anbieter-Übersicht (PAYONE, Stripe, PayPal, Mollie)
|
||||||
|
|
||||||
|
### GF-Ansicht (Alois)
|
||||||
|
- Klare Ampel-Karten: Offene Störungen, betroffener Umsatz, PAYONE-Probleme
|
||||||
|
- Anbieter-Status auf einen Blick
|
||||||
|
- Aktive Störungen mit Schwere und Dauer
|
||||||
|
- Letzte Vorfälle als Tabelle
|
||||||
|
|
||||||
|
### Incident-Detail
|
||||||
|
- Vollständiger Kommunikationsverlauf als Timeline
|
||||||
|
- Aktivitäten hinzufügen (E-Mail, Telefonat, Ticket, Notiz, Anbieter-Antwort)
|
||||||
|
- Status direkt ändern (löst automatisch Aktivitätseintrag aus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Ausbaustufen (optional)
|
||||||
|
|
||||||
|
- **Automatischer Uptime-Check**: Artisan Command, der PAYONE/Stripe-Endpunkte
|
||||||
|
periodisch prüft und bei Ausfall automatisch einen Incident anlegt:
|
||||||
|
```bash
|
||||||
|
php artisan payment:check-uptime
|
||||||
|
```
|
||||||
|
|
||||||
|
- **E-Mail-Benachrichtigung**: Bei neuem Critical-Incident automatisch Mail
|
||||||
|
an kevin@adametz-media.de
|
||||||
|
|
||||||
|
- **Rollen**: Middleware, die Alois nur auf `/management` weiterleitet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technik
|
||||||
|
|
||||||
|
- Laravel (Blade Templates, Eloquent, Form Requests)
|
||||||
|
- Kein zusätzliches JS-Framework – reines Blade + CSS
|
||||||
|
- Dark Mode Design, responsive
|
||||||
|
- Alle Labels auf Deutsch
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\IncidentActivity;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PaymentDashboardController extends Controller
|
||||||
|
{
|
||||||
|
// ─── GF-Ansicht (Alois) ───────────────────────────────────────────────────
|
||||||
|
public function management()
|
||||||
|
{
|
||||||
|
$stats = $this->getStats();
|
||||||
|
$recentIncidents = PaymentIncident::orderBy('detected_at', 'desc')->take(5)->get();
|
||||||
|
$openIncidents = PaymentIncident::whereIn('status', ['open', 'in_progress', 'waiting_provider'])->get();
|
||||||
|
$providerStats = $this->getProviderStats();
|
||||||
|
|
||||||
|
return view('dashboard.management', compact('stats', 'recentIncidents', 'openIncidents', 'providerStats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Entwickler-Ansicht (Kevin) ───────────────────────────────────────────
|
||||||
|
public function developer()
|
||||||
|
{
|
||||||
|
$stats = $this->getStats();
|
||||||
|
$allIncidents = PaymentIncident::with('activities')->orderBy('detected_at', 'desc')->paginate(20);
|
||||||
|
$openIncidents = PaymentIncident::whereIn('status', ['open', 'in_progress', 'waiting_provider'])->with('activities')->get();
|
||||||
|
$providerStats = $this->getProviderStats();
|
||||||
|
$recentActivity = IncidentActivity::with('incident')->orderBy('created_at', 'desc')->take(10)->get();
|
||||||
|
|
||||||
|
return view('dashboard.developer', compact('stats', 'allIncidents', 'openIncidents', 'providerStats', 'recentActivity'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Incident erstellen ───────────────────────────────────────────────────
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'provider' => 'required|in:payone,stripe,paypal,mollie,other',
|
||||||
|
'type' => 'required|in:outage,ipn_error,payment_failure,slow_response,other',
|
||||||
|
'severity' => 'required|in:low,medium,high,critical',
|
||||||
|
'affected_orders' => 'nullable|integer|min:0',
|
||||||
|
'affected_revenue' => 'nullable|numeric|min:0',
|
||||||
|
'ticket_number' => 'nullable|string|max:100',
|
||||||
|
'detected_at' => 'required|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create($validated);
|
||||||
|
|
||||||
|
// Erste Aktivität automatisch anlegen
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'note',
|
||||||
|
'title' => 'Incident eröffnet',
|
||||||
|
'content' => $validated['description'] ?? null,
|
||||||
|
'author' => auth()->user()->name ?? 'Kevin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('payment-dashboard.developer')
|
||||||
|
->with('success', 'Incident erfolgreich angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aktivität hinzufügen ─────────────────────────────────────────────────
|
||||||
|
public function addActivity(Request $request, PaymentIncident $incident)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => 'required|in:note,email,call,ticket,status_change,provider_response',
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'content' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => $validated['type'],
|
||||||
|
'title' => $validated['title'],
|
||||||
|
'content' => $validated['content'] ?? null,
|
||||||
|
'author' => auth()->user()->name ?? 'Kevin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Status automatisch auf "in_progress" setzen wenn noch offen
|
||||||
|
if ($incident->status === 'open') {
|
||||||
|
$incident->update(['status' => 'in_progress']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Aktivität hinzugefügt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status ändern ────────────────────────────────────────────────────────
|
||||||
|
public function updateStatus(Request $request, PaymentIncident $incident)
|
||||||
|
{
|
||||||
|
$request->validate(['status' => 'required|in:open,in_progress,waiting_provider,resolved,closed']);
|
||||||
|
|
||||||
|
$oldStatus = $incident->status_label;
|
||||||
|
$incident->update([
|
||||||
|
'status' => $request->status,
|
||||||
|
'resolved_at' => in_array($request->status, ['resolved', 'closed']) ? now() : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
IncidentActivity::create([
|
||||||
|
'incident_id' => $incident->id,
|
||||||
|
'type' => 'status_change',
|
||||||
|
'title' => 'Status geändert: '.$oldStatus.' → '.$incident->fresh()->status_label,
|
||||||
|
'author' => auth()->user()->name ?? 'Kevin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Status aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Incident Detail ──────────────────────────────────────────────────────
|
||||||
|
public function show(PaymentIncident $incident)
|
||||||
|
{
|
||||||
|
$incident->load('activities');
|
||||||
|
|
||||||
|
return view('dashboard.show', compact('incident'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
private function getStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'open_incidents' => PaymentIncident::whereIn('status', ['open', 'waiting_provider'])->count(),
|
||||||
|
'in_progress' => PaymentIncident::where('status', 'in_progress')->count(),
|
||||||
|
'resolved_this_month' => PaymentIncident::where('status', 'resolved')
|
||||||
|
->whereMonth('resolved_at', now()->month)->count(),
|
||||||
|
'total_affected_revenue' => PaymentIncident::whereIn('status', ['open', 'in_progress', 'waiting_provider'])
|
||||||
|
->sum('affected_revenue'),
|
||||||
|
'payone_incidents_30d' => PaymentIncident::where('provider', 'payone')
|
||||||
|
->where('detected_at', '>=', now()->subDays(30))->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getProviderStats(): array
|
||||||
|
{
|
||||||
|
$providers = ['payone', 'stripe', 'paypal', 'mollie'];
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$stats[$provider] = [
|
||||||
|
'label' => strtoupper($provider),
|
||||||
|
'open_incidents' => PaymentIncident::where('provider', $provider)
|
||||||
|
->whereIn('status', ['open', 'in_progress', 'waiting_provider'])->count(),
|
||||||
|
'total_30d' => PaymentIncident::where('provider', $provider)
|
||||||
|
->where('detected_at', '>=', now()->subDays(30))->count(),
|
||||||
|
'last_incident' => PaymentIncident::where('provider', $provider)
|
||||||
|
->orderBy('detected_at', 'desc')->first(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
dev/payment-dashboard/app/Models/IncidentActivity.php
Normal file
40
dev/payment-dashboard/app/Models/IncidentActivity.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class IncidentActivity extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['incident_id', 'type', 'title', 'content', 'author'];
|
||||||
|
|
||||||
|
public function incident(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PaymentIncident::class, 'incident_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'email' => '✉️',
|
||||||
|
'call' => '📞',
|
||||||
|
'ticket' => '🎫',
|
||||||
|
'status_change' => '🔄',
|
||||||
|
'provider_response' => '💬',
|
||||||
|
default => '📝',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'email' => 'E-Mail',
|
||||||
|
'call' => 'Telefonat',
|
||||||
|
'ticket' => 'Support-Ticket',
|
||||||
|
'status_change' => 'Statusänderung',
|
||||||
|
'provider_response' => 'Anbieter-Antwort',
|
||||||
|
default => 'Notiz',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
74
dev/payment-dashboard/app/Models/PaymentIncident.php
Normal file
74
dev/payment-dashboard/app/Models/PaymentIncident.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PaymentIncident extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'title', 'description', 'provider', 'type', 'status',
|
||||||
|
'severity', 'affected_orders', 'affected_revenue',
|
||||||
|
'detected_at', 'resolved_at', 'ticket_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'detected_at' => 'datetime',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
'affected_revenue' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function activities(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(IncidentActivity::class, 'incident_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDurationAttribute(): string
|
||||||
|
{
|
||||||
|
$end = $this->resolved_at ?? now();
|
||||||
|
$diff = $this->detected_at->diff($end);
|
||||||
|
if ($diff->days > 0) {
|
||||||
|
return $diff->days.'d '.$diff->h.'h';
|
||||||
|
}
|
||||||
|
if ($diff->h > 0) {
|
||||||
|
return $diff->h.'h '.$diff->i.'min';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diff->i.' min';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverityColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => '#ef4444',
|
||||||
|
'high' => '#f97316',
|
||||||
|
'medium' => '#eab308',
|
||||||
|
'low' => '#22c55e',
|
||||||
|
default => '#6b7280',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
'open' => 'Offen',
|
||||||
|
'in_progress' => 'In Bearbeitung',
|
||||||
|
'waiting_provider' => 'Wartet auf Anbieter',
|
||||||
|
'resolved' => 'Gelöst',
|
||||||
|
'closed' => 'Geschlossen',
|
||||||
|
default => $this->status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->provider) {
|
||||||
|
'payone' => 'PAYONE',
|
||||||
|
'stripe' => 'Stripe',
|
||||||
|
'paypal' => 'PayPal',
|
||||||
|
'mollie' => 'Mollie',
|
||||||
|
default => 'Sonstige',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('payment_incidents', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie', 'other'])->default('payone');
|
||||||
|
$table->enum('type', ['outage', 'ipn_error', 'payment_failure', 'slow_response', 'other'])->default('other');
|
||||||
|
$table->enum('status', ['open', 'in_progress', 'waiting_provider', 'resolved', 'closed'])->default('open');
|
||||||
|
$table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium');
|
||||||
|
$table->integer('affected_orders')->default(0);
|
||||||
|
$table->decimal('affected_revenue', 10, 2)->default(0);
|
||||||
|
$table->timestamp('detected_at');
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->string('ticket_number')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('incident_activities', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('incident_id')->constrained('payment_incidents')->onDelete('cascade');
|
||||||
|
$table->enum('type', ['note', 'email', 'call', 'ticket', 'status_change', 'provider_response']);
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('content')->nullable();
|
||||||
|
$table->string('author')->default('Kevin');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('provider_uptime_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->enum('provider', ['payone', 'stripe', 'paypal', 'mollie']);
|
||||||
|
$table->boolean('is_up')->default(true);
|
||||||
|
$table->integer('response_time_ms')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->timestamp('checked_at');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('provider_uptime_logs');
|
||||||
|
Schema::dropIfExists('incident_activities');
|
||||||
|
Schema::dropIfExists('payment_incidents');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
@extends('layouts.dashboard')
|
||||||
|
@section('page-title', 'Entwickler-Dashboard')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- ── Stats ── --}}
|
||||||
|
<div class="grid-4 mb-6">
|
||||||
|
<div class="stat-card {{ $stats['open_incidents'] > 0 ? 'danger' : 'ok' }}">
|
||||||
|
<div class="stat-label">Offen / Wartend</div>
|
||||||
|
<div class="stat-value">{{ $stats['open_incidents'] }}</div>
|
||||||
|
<div class="stat-sub">Incidents ohne Lösung</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card {{ $stats['in_progress'] > 0 ? 'warning' : 'ok' }}">
|
||||||
|
<div class="stat-label">In Bearbeitung</div>
|
||||||
|
<div class="stat-value">{{ $stats['in_progress'] }}</div>
|
||||||
|
<div class="stat-sub">Aktiv bearbeitet</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card ok">
|
||||||
|
<div class="stat-label">Gelöst diesen Monat</div>
|
||||||
|
<div class="stat-value">{{ $stats['resolved_this_month'] }}</div>
|
||||||
|
<div class="stat-sub">{{ now()->format('F Y') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card {{ $stats['payone_incidents_30d'] >= 3 ? 'danger' : ($stats['payone_incidents_30d'] >= 1 ? 'warning' : 'ok') }}">
|
||||||
|
<div class="stat-label">PAYONE (30 Tage)</div>
|
||||||
|
<div class="stat-value">{{ $stats['payone_incidents_30d'] }}</div>
|
||||||
|
<div class="stat-sub">Incidents bei PAYONE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Anbieter-Status ── --}}
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="card-title" style="margin:0">Anbieter-Status</div>
|
||||||
|
</div>
|
||||||
|
<div class="provider-grid">
|
||||||
|
@foreach($providerStats as $key => $provider)
|
||||||
|
<div class="provider-card {{ $provider['open_incidents'] > 0 ? 'has-issues' : 'ok' }}">
|
||||||
|
<div class="provider-name">{{ $provider['label'] }}</div>
|
||||||
|
<div class="provider-incidents">{{ $provider['open_incidents'] }}</div>
|
||||||
|
<div class="provider-sub">offene Störungen</div>
|
||||||
|
<div style="margin-top:8px; font-size:11px; color: var(--text-muted);">{{ $provider['total_30d'] }}× in 30 Tagen</div>
|
||||||
|
@if($provider['last_incident'])
|
||||||
|
<div style="margin-top:4px; font-size:10px; color: var(--text-muted);">
|
||||||
|
Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y H:i') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
{{-- ── Offene Incidents ── --}}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="section-title" style="margin:0; border:none; padding:0;">Offene Incidents</div>
|
||||||
|
<button class="btn btn-primary" onclick="document.getElementById('modal-new').classList.add('open')">
|
||||||
|
+ Neuer Incident
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@forelse($openIncidents as $incident)
|
||||||
|
<div class="card mb-4" style="border-left: 3px solid {{ $incident->severity_color }};">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700; margin-bottom:4px;">{{ $incident->title }}</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span>
|
||||||
|
<span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span>
|
||||||
|
<span style="font-size:11px; color:var(--text-muted); padding: 3px 0;">{{ $incident->provider_label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('payment-dashboard.show', $incident) }}" class="btn btn-ghost" style="font-size:12px;">Detail →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:12px; color:var(--text-muted); margin-bottom:12px;">
|
||||||
|
Erkannt: {{ $incident->detected_at->format('d.m.Y H:i') }} · Dauer: {{ $incident->duration }}
|
||||||
|
@if($incident->ticket_number)
|
||||||
|
· Ticket: <span class="text-accent">{{ $incident->ticket_number }}</span>
|
||||||
|
@endif
|
||||||
|
@if($incident->affected_orders > 0)
|
||||||
|
· {{ $incident->affected_orders }} Bestellungen betroffen
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Letzte Aktivität --}}
|
||||||
|
@if($incident->activities->count() > 0)
|
||||||
|
@php $last = $incident->activities->sortByDesc('created_at')->first(); @endphp
|
||||||
|
<div style="background: var(--surface-2); border: 1px solid var(--border); border-radius:7px; padding:10px 12px; font-size:12px;">
|
||||||
|
<span style="color:var(--text-muted);">{{ $last->type_icon }} Letzte Aktivität:</span>
|
||||||
|
<strong>{{ $last->title }}</strong>
|
||||||
|
<span class="text-muted"> – {{ $last->created_at->diffForHumans() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Schnell-Status-Update --}}
|
||||||
|
<form action="{{ route('payment-dashboard.status.update', $incident) }}" method="POST" style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
<select name="status" style="width:auto; flex:1;">
|
||||||
|
<option value="open" {{ $incident->status === 'open' ? 'selected' : '' }}>Offen</option>
|
||||||
|
<option value="in_progress" {{ $incident->status === 'in_progress' ? 'selected' : '' }}>In Bearbeitung</option>
|
||||||
|
<option value="waiting_provider" {{ $incident->status === 'waiting_provider' ? 'selected' : '' }}>Wartet auf Anbieter</option>
|
||||||
|
<option value="resolved" {{ $incident->status === 'resolved' ? 'selected' : '' }}>Gelöst</option>
|
||||||
|
<option value="closed" {{ $incident->status === 'closed' ? 'selected' : '' }}>Geschlossen</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-ghost" style="font-size:12px;">Aktualisieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="card" style="text-align:center; padding:32px; border-color: var(--green);">
|
||||||
|
<div style="font-size:28px; margin-bottom:8px;">✅</div>
|
||||||
|
<div style="font-weight:700; color:var(--green);">Keine offenen Incidents</div>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Letzte Aktivitäten ── --}}
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Kommunikations-Verlauf</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="timeline">
|
||||||
|
@forelse($recentActivity as $activity)
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-title">
|
||||||
|
{{ $activity->type_icon }} {{ $activity->title }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
{{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }}
|
||||||
|
@if($activity->incident)
|
||||||
|
· <a href="{{ route('payment-dashboard.show', $activity->incident) }}">{{ $activity->incident->title }}</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($activity->content)
|
||||||
|
<div class="timeline-content">{{ $activity->content }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-muted">Noch keine Aktivitäten erfasst.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Alle Incidents Tabelle ── --}}
|
||||||
|
<div class="card" style="margin-top:8px;">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="card-title" style="margin:0;">Alle Incidents</div>
|
||||||
|
<span class="text-muted" style="font-size:12px;">{{ $allIncidents->total() }} gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Anbieter</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Schwere</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Erkannt</th>
|
||||||
|
<th>Dauer</th>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($allIncidents as $incident)
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="font-size:11px;">#{{ $incident->id }}</td>
|
||||||
|
<td><strong>{{ $incident->title }}</strong></td>
|
||||||
|
<td>{{ $incident->provider_label }}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text-muted);">{{ $incident->type }}</td>
|
||||||
|
<td><span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
|
||||||
|
<td style="font-size:12px;">{{ $incident->detected_at->format('d.m.Y H:i') }}</td>
|
||||||
|
<td style="font-size:12px;">{{ $incident->duration }}</td>
|
||||||
|
<td style="font-size:12px; color:var(--accent);">{{ $incident->ticket_number ?? '–' }}</td>
|
||||||
|
<td><a href="{{ route('payment-dashboard.show', $incident) }}" class="btn btn-ghost" style="font-size:11px; padding:4px 10px;">Detail</a></td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="10" class="text-muted">Noch keine Incidents.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;">{{ $allIncidents->links() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Modal: Neuer Incident ── --}}
|
||||||
|
<div class="modal-overlay" id="modal-new" onclick="if(event.target===this)this.classList.remove('open')">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Neuen Incident erfassen</div>
|
||||||
|
<form action="{{ route('payment-dashboard.store') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titel *</label>
|
||||||
|
<input type="text" name="title" placeholder="z.B. IPN-Fehler PayPal via PAYONE" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Anbieter *</label>
|
||||||
|
<select name="provider">
|
||||||
|
<option value="payone">PAYONE</option>
|
||||||
|
<option value="stripe">Stripe</option>
|
||||||
|
<option value="paypal">PayPal</option>
|
||||||
|
<option value="mollie">Mollie</option>
|
||||||
|
<option value="other">Sonstige</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Typ *</label>
|
||||||
|
<select name="type">
|
||||||
|
<option value="ipn_error">IPN-Fehler</option>
|
||||||
|
<option value="outage">Komplettausfall</option>
|
||||||
|
<option value="payment_failure">Zahlungsfehler</option>
|
||||||
|
<option value="slow_response">Langsame Reaktion</option>
|
||||||
|
<option value="other">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Schwere *</label>
|
||||||
|
<select name="severity">
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ticket-Nummer</label>
|
||||||
|
<input type="text" name="ticket_number" placeholder="z.B. PAY-20240112">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Betroffene Bestellungen</label>
|
||||||
|
<input type="number" name="affected_orders" value="0" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Betroffener Umsatz (€)</label>
|
||||||
|
<input type="number" name="affected_revenue" value="0" min="0" step="0.01">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Erkannt am *</label>
|
||||||
|
<input type="datetime-local" name="detected_at" value="{{ now()->format('Y-m-d\TH:i') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<textarea name="description" rows="3" placeholder="Was ist passiert? Fehlermeldung, Kontext..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="document.getElementById('modal-new').classList.remove('open')">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Incident anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
@extends('layouts.dashboard')
|
||||||
|
@section('page-title', 'Zahlungssystem – Übersicht')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- ── Stat-Karten ── --}}
|
||||||
|
<div class="grid-4 mb-6">
|
||||||
|
<div class="stat-card {{ $stats['open_incidents'] > 0 ? 'danger' : 'ok' }}">
|
||||||
|
<div class="stat-label">Offene Störungen</div>
|
||||||
|
<div class="stat-value">{{ $stats['open_incidents'] }}</div>
|
||||||
|
<div class="stat-sub">Warten auf Lösung</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card {{ $stats['in_progress'] > 0 ? 'warning' : 'ok' }}">
|
||||||
|
<div class="stat-label">In Bearbeitung</div>
|
||||||
|
<div class="stat-value">{{ $stats['in_progress'] }}</div>
|
||||||
|
<div class="stat-sub">Aktiv bearbeitet</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card {{ $stats['total_affected_revenue'] > 0 ? 'danger' : 'ok' }}">
|
||||||
|
<div class="stat-label">Betroffener Umsatz</div>
|
||||||
|
<div class="stat-value">{{ number_format($stats['total_affected_revenue'], 0, ',', '.') }} €</div>
|
||||||
|
<div class="stat-sub">Offene Incidents</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card {{ $stats['payone_incidents_30d'] >= 3 ? 'danger' : ($stats['payone_incidents_30d'] >= 1 ? 'warning' : 'ok') }}">
|
||||||
|
<div class="stat-label">PAYONE Probleme</div>
|
||||||
|
<div class="stat-value">{{ $stats['payone_incidents_30d'] }}</div>
|
||||||
|
<div class="stat-sub">Letzte 30 Tage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Anbieter-Status ── --}}
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="card-title">Anbieter-Übersicht</div>
|
||||||
|
<div class="provider-grid">
|
||||||
|
@foreach($providerStats as $key => $provider)
|
||||||
|
<div class="provider-card {{ $provider['open_incidents'] > 0 ? 'has-issues' : 'ok' }}">
|
||||||
|
<div class="provider-name">{{ $provider['label'] }}</div>
|
||||||
|
<div class="provider-incidents">{{ $provider['open_incidents'] }}</div>
|
||||||
|
<div class="provider-sub">offene Störungen</div>
|
||||||
|
<div class="provider-sub" style="margin-top:6px;">{{ $provider['total_30d'] }}× in 30 Tagen</div>
|
||||||
|
@if($provider['last_incident'])
|
||||||
|
<div class="provider-sub" style="margin-top:4px; font-size:10px;">
|
||||||
|
Zuletzt: {{ $provider['last_incident']->detected_at->format('d.m.Y') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Offene Störungen ── --}}
|
||||||
|
@if($openIncidents->count() > 0)
|
||||||
|
<div class="card mb-6" style="border-color: var(--red);">
|
||||||
|
<div class="card-title" style="color: var(--red);">⚠ Aktive Störungen</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Störung</th>
|
||||||
|
<th>Anbieter</th>
|
||||||
|
<th>Schwere</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Seit</th>
|
||||||
|
<th>Betroffene Bestellungen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($openIncidents as $incident)
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ $incident->title }}</strong></td>
|
||||||
|
<td>{{ $incident->provider_label }}</td>
|
||||||
|
<td><span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span></td>
|
||||||
|
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
|
||||||
|
<td>{{ $incident->detected_at->format('d.m.Y H:i') }}<br><span class="text-muted">{{ $incident->duration }}</span></td>
|
||||||
|
<td>{{ $incident->affected_orders > 0 ? $incident->affected_orders . ' Bestellungen' : '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card mb-6" style="border-color: var(--green); text-align:center; padding: 32px;">
|
||||||
|
<div style="font-size: 32px; margin-bottom: 8px;">✅</div>
|
||||||
|
<div style="font-weight: 700; color: var(--green); margin-bottom: 4px;">Keine aktiven Störungen</div>
|
||||||
|
<div class="text-muted">Alle Zahlungssysteme laufen normal.</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── Letzte Incidents ── --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Letzte Vorfälle</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Anbieter</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Dauer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($recentIncidents as $incident)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $incident->title }}</td>
|
||||||
|
<td>{{ $incident->provider_label }}</td>
|
||||||
|
<td>{{ $incident->detected_at->format('d.m.Y') }}</td>
|
||||||
|
<td><span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span></td>
|
||||||
|
<td>{{ $incident->duration }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="text-muted">Noch keine Vorfälle erfasst.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
150
dev/payment-dashboard/resources/views/dashboard/show.blade.php
Normal file
150
dev/payment-dashboard/resources/views/dashboard/show.blade.php
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
@extends('layouts.dashboard')
|
||||||
|
@section('page-title', 'Incident #' . $incident->id)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-6" style="flex-wrap:wrap;">
|
||||||
|
<a href="{{ route('payment-dashboard.developer') }}" class="btn btn-ghost" style="font-size:12px;">← Zurück</a>
|
||||||
|
<span class="badge badge-{{ $incident->severity }}">{{ ucfirst($incident->severity) }}</span>
|
||||||
|
<span class="badge badge-{{ $incident->status }}">{{ $incident->status_label }}</span>
|
||||||
|
<span style="font-size:13px; color:var(--text-muted);">{{ $incident->provider_label }} · {{ $incident->detected_at->format('d.m.Y H:i') }} · {{ $incident->duration }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
{{-- ── Incident-Details ── --}}
|
||||||
|
<div>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-title">Incident-Details</div>
|
||||||
|
<div style="font-size:20px; font-weight:800; margin-bottom:12px;">{{ $incident->title }}</div>
|
||||||
|
|
||||||
|
@if($incident->description)
|
||||||
|
<div style="background:var(--surface-2); border:1px solid var(--border); border-radius:7px; padding:14px; font-size:13px; color:var(--text-muted); margin-bottom:16px;">
|
||||||
|
{{ $incident->description }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<table style="width:100%; font-size:13px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted); width:140px;">Anbieter</td>
|
||||||
|
<td style="padding:6px 0; font-weight:600;">{{ $incident->provider_label }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Typ</td>
|
||||||
|
<td style="padding:6px 0;">{{ $incident->type }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Erkannt</td>
|
||||||
|
<td style="padding:6px 0;">{{ $incident->detected_at->format('d.m.Y H:i') }} Uhr</td>
|
||||||
|
</tr>
|
||||||
|
@if($incident->resolved_at)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Gelöst</td>
|
||||||
|
<td style="padding:6px 0; color:var(--green);">{{ $incident->resolved_at->format('d.m.Y H:i') }} Uhr</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Dauer</td>
|
||||||
|
<td style="padding:6px 0;">{{ $incident->duration }}</td>
|
||||||
|
</tr>
|
||||||
|
@if($incident->ticket_number)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Ticket-Nr.</td>
|
||||||
|
<td style="padding:6px 0; color:var(--accent); font-weight:600;">{{ $incident->ticket_number }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@if($incident->affected_orders > 0)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Bestellungen</td>
|
||||||
|
<td style="padding:6px 0; color:var(--red);">{{ $incident->affected_orders }} betroffen</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@if($incident->affected_revenue > 0)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:var(--text-muted);">Umsatz</td>
|
||||||
|
<td style="padding:6px 0; color:var(--red); font-weight:700;">{{ number_format($incident->affected_revenue, 2, ',', '.') }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Status ändern --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-title">Status aktualisieren</div>
|
||||||
|
<form action="{{ route('payment-dashboard.status.update', $incident) }}" method="POST" class="flex gap-2">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
<select name="status" style="flex:1;">
|
||||||
|
<option value="open" {{ $incident->status === 'open' ? 'selected' : '' }}>Offen</option>
|
||||||
|
<option value="in_progress" {{ $incident->status === 'in_progress' ? 'selected' : '' }}>In Bearbeitung</option>
|
||||||
|
<option value="waiting_provider" {{ $incident->status === 'waiting_provider' ? 'selected' : '' }}>Wartet auf Anbieter</option>
|
||||||
|
<option value="resolved" {{ $incident->status === 'resolved' ? 'selected' : '' }}>Gelöst</option>
|
||||||
|
<option value="closed" {{ $incident->status === 'closed' ? 'selected' : '' }}>Geschlossen</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Aktivität hinzufügen --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Aktivität hinzufügen</div>
|
||||||
|
<form action="{{ route('payment-dashboard.activity.store', $incident) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Typ</label>
|
||||||
|
<select name="type">
|
||||||
|
<option value="note">📝 Notiz</option>
|
||||||
|
<option value="email">✉️ E-Mail</option>
|
||||||
|
<option value="call">📞 Telefonat</option>
|
||||||
|
<option value="ticket">🎫 Support-Ticket</option>
|
||||||
|
<option value="provider_response">💬 Anbieter-Antwort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titel *</label>
|
||||||
|
<input type="text" name="title" placeholder="z.B. Ticket bei PAYONE eingereicht" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Details</label>
|
||||||
|
<textarea name="content" rows="3" placeholder="Inhalt der Mail, Antwort des Anbieters, Notizen..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Aktivität speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Aktivitäts-Timeline ── --}}
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Kommunikations-Verlauf ({{ $incident->activities->count() }} Einträge)</div>
|
||||||
|
<div class="card">
|
||||||
|
@if($incident->activities->count() > 0)
|
||||||
|
<div class="timeline">
|
||||||
|
@foreach($incident->activities->sortByDesc('created_at') as $activity)
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot" style="background: {{ match($activity->type) {
|
||||||
|
'provider_response' => 'var(--green)',
|
||||||
|
'email' => 'var(--accent)',
|
||||||
|
'call' => 'var(--yellow)',
|
||||||
|
'ticket' => 'var(--orange)',
|
||||||
|
'status_change' => 'var(--text-muted)',
|
||||||
|
default => 'var(--accent)'
|
||||||
|
} }};"></div>
|
||||||
|
<div class="timeline-title">
|
||||||
|
{{ $activity->type_icon }} {{ $activity->title }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
{{ $activity->type_label }} · {{ $activity->author }} · {{ $activity->created_at->format('d.m.Y H:i') }} Uhr
|
||||||
|
· <span style="color: var(--text-muted)">{{ $activity->created_at->diffForHumans() }}</span>
|
||||||
|
</div>
|
||||||
|
@if($activity->content)
|
||||||
|
<div class="timeline-content">{{ $activity->content }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-muted" style="text-align:center; padding:24px;">Noch keine Aktivitäten erfasst.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
@ -0,0 +1,402 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<title>@yield('title', 'Payment Dashboard') – mivita</title>
|
||||||
|
<style>
|
||||||
|
/* ── Reset & Base ── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d0f14;
|
||||||
|
--surface: #161920;
|
||||||
|
--surface-2: #1e2230;
|
||||||
|
--border: #2a2f3f;
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-muted: #7c839a;
|
||||||
|
--accent: #4f8ef7;
|
||||||
|
--accent-soft: #1a2a4a;
|
||||||
|
--red: #ef4444;
|
||||||
|
--red-soft: #3a1515;
|
||||||
|
--orange: #f97316;
|
||||||
|
--orange-soft: #3a2010;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--yellow-soft: #332d00;
|
||||||
|
--green: #22c55e;
|
||||||
|
--green-soft: #0f2d1a;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
.layout { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 24px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sidebar-logo span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.sidebar-logo strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav { padding: 0 12px; flex: 1; }
|
||||||
|
.nav-section {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 16px 8px 6px;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.nav-link.active { color: var(--accent); }
|
||||||
|
.nav-icon { font-size: 15px; width: 20px; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 16px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.topbar-title { font-size: 18px; font-weight: 700; }
|
||||||
|
.topbar-meta { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.content { padding: 32px; overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Cards & Grid ── */
|
||||||
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 24px; }
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat Cards ── */
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.stat-sub { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.stat-card.danger { border-color: var(--red); background: var(--red-soft); }
|
||||||
|
.stat-card.warning { border-color: var(--orange); background: var(--orange-soft); }
|
||||||
|
.stat-card.ok { border-color: var(--green); background: var(--green-soft); }
|
||||||
|
|
||||||
|
/* ── Badges ── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.badge-critical { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
|
||||||
|
.badge-high { background: var(--orange-soft); color: var(--orange); border: 1px solid var(--orange); }
|
||||||
|
.badge-medium { background: var(--yellow-soft); color: var(--yellow); border: 1px solid var(--yellow); }
|
||||||
|
.badge-low { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.badge-open { background: #1a1a2e; color: #818cf8; border: 1px solid #818cf8; }
|
||||||
|
.badge-in_progress { background: #1a2a3a; color: var(--accent); border: 1px solid var(--accent); }
|
||||||
|
.badge-waiting_provider { background: var(--orange-soft); color: var(--orange); border: 1px solid var(--orange); }
|
||||||
|
.badge-resolved { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.badge-closed { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── Table ── */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* ── Timeline ── */
|
||||||
|
.timeline { position: relative; padding-left: 28px; }
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; top: 8px; bottom: 8px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -24px;
|
||||||
|
top: 4px;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.timeline-meta { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
|
||||||
|
.timeline-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Provider Status ── */
|
||||||
|
.provider-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||||
|
.provider-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.provider-name { font-size: 13px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.provider-incidents { font-size: 28px; font-weight: 800; margin-bottom: 2px; }
|
||||||
|
.provider-sub { font-size: 11px; color: var(--text-muted); }
|
||||||
|
.provider-card.has-issues { border-color: var(--red); }
|
||||||
|
.provider-card.has-issues .provider-incidents { color: var(--red); }
|
||||||
|
.provider-card.ok .provider-incidents { color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Forms ── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: #3a7ef0; }
|
||||||
|
.btn-ghost { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); }
|
||||||
|
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-danger { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
|
||||||
|
|
||||||
|
select, input, textarea {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
select:focus, input:focus, textarea:focus { border-color: var(--accent); }
|
||||||
|
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
|
||||||
|
/* ── Alert ── */
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.alert-success { background: var(--green-soft); color: var(--green); border: 1px solid var(--green); }
|
||||||
|
.alert-danger { background: var(--red-soft); color: var(--red); border: 1px solid var(--red); }
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px;
|
||||||
|
width: 560px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* ── Misc ── */
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-red { color: var(--red); }
|
||||||
|
.text-green { color: var(--green); }
|
||||||
|
.text-accent { color: var(--accent); }
|
||||||
|
.mb-4 { margin-bottom: 16px; }
|
||||||
|
.mb-6 { margin-bottom: 24px; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-2 { gap: 8px; }
|
||||||
|
.gap-3 { gap: 12px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap');
|
||||||
|
</style>
|
||||||
|
@stack('styles')
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<span>mivita</span>
|
||||||
|
<strong>Payment Monitor</strong>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-section">Ansichten</div>
|
||||||
|
<a href="{{ route('payment-dashboard.developer') }}" class="nav-link {{ request()->routeIs('payment-dashboard.developer') ? 'active' : '' }}">
|
||||||
|
<span class="nav-icon">⚙️</span> Entwickler
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('payment-dashboard.management') }}" class="nav-link {{ request()->routeIs('payment-dashboard.management') ? 'active' : '' }}">
|
||||||
|
<span class="nav-icon">📊</span> Management
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<div class="topbar-title">@yield('page-title', 'Dashboard')</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-meta">
|
||||||
|
Stand: {{ now()->format('d.m.Y H:i') }} Uhr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success">✓ {{ session('success') }}</div>
|
||||||
|
@endif
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="alert alert-danger">✗ {{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@yield('content')
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@stack('scripts')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
dev/payment-dashboard/routes/payment-dashboard.php
Normal file
35
dev/payment-dashboard/routes/payment-dashboard.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\PaymentDashboardController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// ─── Payment Dashboard Routes ─────────────────────────────────────────────────
|
||||||
|
// In web.php einbinden mit: require base_path('routes/payment-dashboard.php');
|
||||||
|
// Oder direkt in web.php einfügen.
|
||||||
|
|
||||||
|
Route::prefix('payment-dashboard')->name('payment-dashboard.')->middleware(['auth'])->group(function () {
|
||||||
|
|
||||||
|
// GF-Ansicht (Alois) – vereinfacht, nur lesen
|
||||||
|
Route::get('/management', [PaymentDashboardController::class, 'management'])
|
||||||
|
->name('management');
|
||||||
|
|
||||||
|
// Entwickler-Ansicht (Kevin) – voller Zugriff
|
||||||
|
Route::get('/', [PaymentDashboardController::class, 'developer'])
|
||||||
|
->name('developer');
|
||||||
|
|
||||||
|
// Incident Detail
|
||||||
|
Route::get('/{incident}', [PaymentDashboardController::class, 'show'])
|
||||||
|
->name('show');
|
||||||
|
|
||||||
|
// Neuen Incident anlegen
|
||||||
|
Route::post('/', [PaymentDashboardController::class, 'store'])
|
||||||
|
->name('store');
|
||||||
|
|
||||||
|
// Aktivität zu Incident hinzufügen
|
||||||
|
Route::post('/{incident}/activity', [PaymentDashboardController::class, 'addActivity'])
|
||||||
|
->name('activity.store');
|
||||||
|
|
||||||
|
// Status eines Incidents ändern
|
||||||
|
Route::patch('/{incident}/status', [PaymentDashboardController::class, 'updateStatus'])
|
||||||
|
->name('status.update');
|
||||||
|
});
|
||||||
|
|
@ -63,8 +63,12 @@ return [
|
||||||
'no_participants' => 'Noch keine Teilnehmer.',
|
'no_participants' => 'Noch keine Teilnehmer.',
|
||||||
'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.',
|
'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.',
|
||||||
'anonymous_consultant' => 'Anonymer Berater',
|
'anonymous_consultant' => 'Anonymer Berater',
|
||||||
|
'ranking_all_active' => 'Alle Aktiven',
|
||||||
|
'vip_view_notice' => 'VIP-Ansicht: Klarnamen aller Teilnehmer werden angezeigt.',
|
||||||
|
'vip_terms_accepted' => 'Teilnahmebedingungen akzeptiert',
|
||||||
|
'vip_terms_pending' => 'Teilnahmebedingungen noch nicht akzeptiert',
|
||||||
'ranking_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.',
|
'ranking_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.',
|
||||||
'ranking_extended_hint' => 'Die Liste zeigt die Plätze 1–30. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.',
|
'ranking_extended_hint' => 'Die Liste zeigt alle Berater mit mehr als 0 Punkten. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.',
|
||||||
'calculation_details' => 'Berechnungsdetails',
|
'calculation_details' => 'Berechnungsdetails',
|
||||||
'close' => 'Schliessen',
|
'close' => 'Schliessen',
|
||||||
|
|
||||||
|
|
@ -136,6 +140,14 @@ return [
|
||||||
'you_participate' => 'Du nimmst teil!',
|
'you_participate' => 'Du nimmst teil!',
|
||||||
'your_rank' => 'Dein aktueller Rang',
|
'your_rank' => 'Dein aktueller Rang',
|
||||||
'participate_intro' => 'Bist du bereit für die Challenge? Melde dich einmalig an, um im offiziellen Ranking gelistet zu werden.',
|
'participate_intro' => 'Bist du bereit für die Challenge? Melde dich einmalig an, um im offiziellen Ranking gelistet zu werden.',
|
||||||
|
'dash_notice_unregistered_title' => 'Noch nicht angemeldet',
|
||||||
|
'dash_notice_unregistered_body' => 'Du nimmst am Incentive noch nicht offiziell teil. Ohne Bestätigung werden deine Punkte nicht gewertet und du erscheinst nicht in der Rangliste.',
|
||||||
|
'dash_notice_unconfirmed_title' => 'Teilnahme noch nicht bestätigt',
|
||||||
|
'dash_notice_unconfirmed_body' => 'Deine Punkte laufen bereits mit – aber ohne Bestätigung der Teilnahmebedingungen wirst du in der Rangliste anonym angezeigt und kannst nicht gewinnen.',
|
||||||
|
'dash_notice_btn' => 'Jetzt Teilnahme bestätigen',
|
||||||
|
'dash_modal_title' => 'Teilnahme bestätigen',
|
||||||
|
'dash_modal_intro' => 'Bitte lies die Informationen und Teilnahmebedingungen sorgfältig durch und bestätige anschließend deine Teilnahme.',
|
||||||
|
'dash_modal_cancel' => 'Schließen',
|
||||||
'pending_confirmation_banner' => 'Deine Punkte werden bereits im Qualifikationszeitraum mitgerechnet. Bitte bestätige die Teilnahme, damit dein Name in der Rangliste sichtbar wird und du alle Funktionen nutzen kannst.',
|
'pending_confirmation_banner' => 'Deine Punkte werden bereits im Qualifikationszeitraum mitgerechnet. Bitte bestätige die Teilnahme, damit dein Name in der Rangliste sichtbar wird und du alle Funktionen nutzen kannst.',
|
||||||
'details_requires_confirmation' => 'Die Detailansicht ist erst nach Bestätigung der Teilnahme verfügbar.',
|
'details_requires_confirmation' => 'Die Detailansicht ist erst nach Bestätigung der Teilnahme verfügbar.',
|
||||||
'participate_abo_hint' => 'Es liegt mindestens ein für die Wertung relevantes Abo vor (aktives Berater-Abo oder Kundenabo im Qualifikationszeitraum). Mit dem Teilnehmen werden die Punkte dafür direkt nach den aktuellen Regeln übernommen.',
|
'participate_abo_hint' => 'Es liegt mindestens ein für die Wertung relevantes Abo vor (aktives Berater-Abo oder Kundenabo im Qualifikationszeitraum). Mit dem Teilnehmen werden die Punkte dafür direkt nach den aktuellen Regeln übernommen.',
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,6 @@ return [
|
||||||
'my_abo' => 'Mein Abo',
|
'my_abo' => 'Mein Abo',
|
||||||
'my_subscriptions' => 'Meine Abos',
|
'my_subscriptions' => 'Meine Abos',
|
||||||
'team_customers' => 'Team Kunden',
|
'team_customers' => 'Team Kunden',
|
||||||
|
'payment_monitor' => 'Payment Monitor',
|
||||||
|
'payment_monitor_management' => 'Payment Monitor GF',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
6
resources/lang/de/pagination.php
Normal file
6
resources/lang/de/pagination.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'previous' => '« Zurück',
|
||||||
|
'next' => 'Weiter »',
|
||||||
|
];
|
||||||
|
|
@ -161,12 +161,32 @@ return [
|
||||||
'payment_not_found' => 'Zahlung nicht gefunden',
|
'payment_not_found' => 'Zahlung nicht gefunden',
|
||||||
'payment_not_found_description' => 'Die Zahlung mit der Referenz :reference konnte nicht gefunden werden. Bitte kontaktieren Sie uns, falls Sie bereits bezahlt haben.',
|
'payment_not_found_description' => 'Die Zahlung mit der Referenz :reference konnte nicht gefunden werden. Bitte kontaktieren Sie uns, falls Sie bereits bezahlt haben.',
|
||||||
'payment_canceled' => 'Zahlung abgebrochen',
|
'payment_canceled' => 'Zahlung abgebrochen',
|
||||||
'payment_canceled_description' => 'Der Zahlungsvorgang wurde abgebrochen. Ihre Bestellung wurde nicht ausgeführt.',
|
'payment_canceled_description' => 'Sie haben den Zahlungsvorgang abgebrochen. Ihre Bestellung wurde nicht ausgeführt und es wurde nichts belastet.',
|
||||||
'payment_error' => 'Zahlungsfehler',
|
'payment_canceled_hint' => 'Sie können jederzeit einen neuen Zahlungsversuch starten.',
|
||||||
'payment_error_description' => 'Bei der Zahlungsabwicklung ist ein Fehler aufgetreten. Ihre Bestellung konnte nicht abgeschlossen werden.',
|
'payment_error' => 'Zahlung fehlgeschlagen',
|
||||||
|
'payment_error_description' => 'Die Zahlung konnte leider nicht abgeschlossen werden.',
|
||||||
|
'payment_error_hint' => 'Bitte prüfen Sie Ihre Zahlungsdaten und versuchen Sie es erneut — oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'payment_error_retry' => 'Erneut versuchen',
|
||||||
|
'payment_error_code' => 'Fehlercode',
|
||||||
|
'payment_error_what_to_do' => 'Was kann ich tun?',
|
||||||
'payment_unknown_status' => 'Unbekannter Zahlungsstatus',
|
'payment_unknown_status' => 'Unbekannter Zahlungsstatus',
|
||||||
'payment_unknown_status_description' => 'Der Zahlungsstatus konnte nicht ermittelt werden. Bitte kontaktieren Sie uns für weitere Informationen.',
|
'payment_unknown_status_description' => 'Der Zahlungsstatus konnte nicht ermittelt werden. Bitte kontaktieren Sie uns für weitere Informationen.',
|
||||||
'contact_support_if_needed' => 'Bei Fragen wenden Sie sich bitte an unseren Kundenservice.',
|
'contact_support_if_needed' => 'Bei weiteren Fragen wenden Sie sich bitte an unseren Kundenservice.',
|
||||||
|
'try_again' => 'Erneut versuchen',
|
||||||
|
'choose_different_payment' => 'Andere Zahlungsart wählen',
|
||||||
|
'nothing_was_charged' => 'Es wurde nichts von Ihrem Konto abgebucht.',
|
||||||
|
'payment_error_reasons' => [
|
||||||
|
'card_expired' => 'Ihre Karte ist abgelaufen. Bitte verwenden Sie eine gültige Karte oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'card_blocked' => 'Ihre Karte ist gesperrt. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'card_invalid' => 'Die Kartendaten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.',
|
||||||
|
'card_declined' => 'Ihre Bank hat die Zahlung abgelehnt. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'insufficient_funds' => 'Das Kartenlimit wurde überschritten. Bitte wenden Sie sich an Ihre Bank oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'cvv_invalid' => 'Die Prüfziffer (CVV) ist nicht korrekt. Bitte überprüfen Sie die 3-stellige Zahl auf der Rückseite Ihrer Karte.',
|
||||||
|
'3ds_failed' => 'Die 3D-Secure-Authentifizierung ist fehlgeschlagen. Bitte versuchen Sie es erneut oder wählen Sie eine andere Zahlungsart.',
|
||||||
|
'timeout' => 'Die Verbindung zur Bank ist unterbrochen (Timeout). Bitte versuchen Sie es in wenigen Minuten erneut.',
|
||||||
|
'fraud' => 'Die Zahlung wurde aus Sicherheitsgründen abgelehnt. Bitte wenden Sie sich an Ihre Bank.',
|
||||||
|
'general' => 'Bitte überprüfen Sie Ihre Zahlungsdaten und versuchen Sie es erneut. Falls das Problem weiterhin besteht, wählen Sie eine andere Zahlungsart.',
|
||||||
|
],
|
||||||
|
|
||||||
// DHL Packstation/Paketbox
|
// DHL Packstation/Paketbox
|
||||||
'packstation_delivery' => 'Lieferung an Packstation/Paketbox',
|
'packstation_delivery' => 'Lieferung an Packstation/Paketbox',
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,12 @@ return [
|
||||||
'no_participants' => 'No participants yet.',
|
'no_participants' => 'No participants yet.',
|
||||||
'no_participants_with_points' => 'No participants with points yet.',
|
'no_participants_with_points' => 'No participants with points yet.',
|
||||||
'anonymous_consultant' => 'Anonymous consultant',
|
'anonymous_consultant' => 'Anonymous consultant',
|
||||||
|
'ranking_all_active' => 'All Active',
|
||||||
|
'vip_view_notice' => 'VIP view: Real names of all participants are shown.',
|
||||||
|
'vip_terms_accepted' => 'Terms accepted',
|
||||||
|
'vip_terms_pending' => 'Terms not yet accepted',
|
||||||
'ranking_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.',
|
'ranking_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.',
|
||||||
'ranking_extended_hint' => 'The list shows ranks 1–30. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.',
|
'ranking_extended_hint' => 'The list shows all consultants with more than 0 points. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.',
|
||||||
'calculation_details' => 'Calculation Details',
|
'calculation_details' => 'Calculation Details',
|
||||||
'close' => 'Close',
|
'close' => 'Close',
|
||||||
|
|
||||||
|
|
@ -136,6 +140,14 @@ return [
|
||||||
'you_participate' => 'You are participating!',
|
'you_participate' => 'You are participating!',
|
||||||
'your_rank' => 'Your current rank',
|
'your_rank' => 'Your current rank',
|
||||||
'participate_intro' => 'Ready for the challenge? Register once to be listed in the official ranking.',
|
'participate_intro' => 'Ready for the challenge? Register once to be listed in the official ranking.',
|
||||||
|
'dash_notice_unregistered_title' => 'Not yet registered',
|
||||||
|
'dash_notice_unregistered_body' => 'You are not yet officially participating. Without confirmation, your points won\'t count and you won\'t appear in the ranking.',
|
||||||
|
'dash_notice_unconfirmed_title' => 'Participation not yet confirmed',
|
||||||
|
'dash_notice_unconfirmed_body' => 'Your points are already tracked – but without accepting the terms you will appear anonymously in the ranking and cannot win.',
|
||||||
|
'dash_notice_btn' => 'Confirm participation now',
|
||||||
|
'dash_modal_title' => 'Confirm participation',
|
||||||
|
'dash_modal_intro' => 'Please read the information and terms carefully, then confirm your participation.',
|
||||||
|
'dash_modal_cancel' => 'Close',
|
||||||
'pending_confirmation_banner' => 'Your points are already counted for the qualification period. Please confirm participation so your name appears in the ranking and you can use all features.',
|
'pending_confirmation_banner' => 'Your points are already counted for the qualification period. Please confirm participation so your name appears in the ranking and you can use all features.',
|
||||||
'details_requires_confirmation' => 'The detail view is available only after you confirm participation.',
|
'details_requires_confirmation' => 'The detail view is available only after you confirm participation.',
|
||||||
'participate_abo_hint' => 'You already have at least one subscription that counts (active consultant subscription or a customer subscription started in the qualification period). When you join, points for it are applied immediately according to the current rules.',
|
'participate_abo_hint' => 'You already have at least one subscription that counts (active consultant subscription or a customer subscription started in the qualification period). When you join, points for it are applied immediately according to the current rules.',
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,6 @@ return [
|
||||||
'my_abo' => 'My Abo',
|
'my_abo' => 'My Abo',
|
||||||
'my_subscriptions' => 'My Subscriptions',
|
'my_subscriptions' => 'My Subscriptions',
|
||||||
'team_customers' => 'Team Customers',
|
'team_customers' => 'Team Customers',
|
||||||
|
'payment_monitor' => 'Payment Monitor',
|
||||||
|
'payment_monitor_management' => 'Payment Monitor (Mgmt)',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
6
resources/lang/en/pagination.php
Normal file
6
resources/lang/en/pagination.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'previous' => '« Previous',
|
||||||
|
'next' => 'Next »',
|
||||||
|
];
|
||||||
|
|
@ -154,12 +154,32 @@ return [
|
||||||
'payment_not_found' => 'Payment not found',
|
'payment_not_found' => 'Payment not found',
|
||||||
'payment_not_found_description' => 'The payment with reference :reference could not be found. Please contact us if you have already paid.',
|
'payment_not_found_description' => 'The payment with reference :reference could not be found. Please contact us if you have already paid.',
|
||||||
'payment_canceled' => 'Payment canceled',
|
'payment_canceled' => 'Payment canceled',
|
||||||
'payment_canceled_description' => 'The payment process was canceled. Your order was not processed.',
|
'payment_canceled_description' => 'You have canceled the payment process. Your order was not processed and nothing has been charged.',
|
||||||
'payment_error' => 'Payment error',
|
'payment_canceled_hint' => 'You can start a new payment attempt at any time.',
|
||||||
'payment_error_description' => 'An error occurred during payment processing. Your order could not be completed.',
|
'payment_error' => 'Payment failed',
|
||||||
|
'payment_error_description' => 'Unfortunately, the payment could not be completed.',
|
||||||
|
'payment_error_hint' => 'Please check your payment details and try again — or choose a different payment method.',
|
||||||
|
'payment_error_retry' => 'Try again',
|
||||||
|
'payment_error_code' => 'Error code',
|
||||||
|
'payment_error_what_to_do' => 'What can I do?',
|
||||||
'payment_unknown_status' => 'Unknown payment status',
|
'payment_unknown_status' => 'Unknown payment status',
|
||||||
'payment_unknown_status_description' => 'The payment status could not be determined. Please contact us for more information.',
|
'payment_unknown_status_description' => 'The payment status could not be determined. Please contact us for more information.',
|
||||||
'contact_support_if_needed' => 'If you have any questions, please contact our customer service.',
|
'contact_support_if_needed' => 'If you have further questions, please contact our customer service.',
|
||||||
|
'try_again' => 'Try again',
|
||||||
|
'choose_different_payment' => 'Choose a different payment method',
|
||||||
|
'nothing_was_charged' => 'Nothing has been charged to your account.',
|
||||||
|
'payment_error_reasons' => [
|
||||||
|
'card_expired' => 'Your card has expired. Please use a valid card or choose a different payment method.',
|
||||||
|
'card_blocked' => 'Your card is blocked. Please contact your bank or choose a different payment method.',
|
||||||
|
'card_invalid' => 'The card details are invalid. Please check your input.',
|
||||||
|
'card_declined' => 'Your bank declined the payment. Please contact your bank or choose a different payment method.',
|
||||||
|
'insufficient_funds' => 'The card limit has been exceeded. Please contact your bank or choose a different payment method.',
|
||||||
|
'cvv_invalid' => 'The security code (CVV) is incorrect. Please check the 3-digit code on the back of your card.',
|
||||||
|
'3ds_failed' => '3D Secure authentication failed. Please try again or choose a different payment method.',
|
||||||
|
'timeout' => 'The connection to the bank was interrupted (timeout). Please try again in a few minutes.',
|
||||||
|
'fraud' => 'The payment was declined for security reasons. Please contact your bank.',
|
||||||
|
'general' => 'Please check your payment details and try again. If the problem persists, please choose a different payment method.',
|
||||||
|
],
|
||||||
|
|
||||||
// DHL Packstation/Parcel Box
|
// DHL Packstation/Parcel Box
|
||||||
'packstation_delivery' => 'Delivery to Packstation/Parcel Box',
|
'packstation_delivery' => 'Delivery to Packstation/Parcel Box',
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,12 @@ return [
|
||||||
'no_participants' => 'Aun no hay participantes.',
|
'no_participants' => 'Aun no hay participantes.',
|
||||||
'no_participants_with_points' => 'Aun no hay participantes con puntos.',
|
'no_participants_with_points' => 'Aun no hay participantes con puntos.',
|
||||||
'anonymous_consultant' => 'Consultor anonimo',
|
'anonymous_consultant' => 'Consultor anonimo',
|
||||||
|
'ranking_all_active' => 'Todos los activos',
|
||||||
|
'vip_view_notice' => 'Vista VIP: Se muestran los nombres reales de todos los participantes.',
|
||||||
|
'vip_terms_accepted' => 'Condiciones aceptadas',
|
||||||
|
'vip_terms_pending' => 'Condiciones aun no aceptadas',
|
||||||
'ranking_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.',
|
'ranking_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.',
|
||||||
'ranking_extended_hint' => 'La lista muestra los puestos 1–30. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.',
|
'ranking_extended_hint' => 'La lista muestra todos los consultores con mas de 0 puntos. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.',
|
||||||
'calculation_details' => 'Detalles del calculo',
|
'calculation_details' => 'Detalles del calculo',
|
||||||
'close' => 'Cerrar',
|
'close' => 'Cerrar',
|
||||||
|
|
||||||
|
|
@ -136,6 +140,14 @@ return [
|
||||||
'you_participate' => 'Estas participando!',
|
'you_participate' => 'Estas participando!',
|
||||||
'your_rank' => 'Tu puesto actual',
|
'your_rank' => 'Tu puesto actual',
|
||||||
'participate_intro' => 'Listo para el desafio? Registrate una vez para aparecer en el ranking oficial.',
|
'participate_intro' => 'Listo para el desafio? Registrate una vez para aparecer en el ranking oficial.',
|
||||||
|
'dash_notice_unregistered_title' => 'Aun no registrado',
|
||||||
|
'dash_notice_unregistered_body' => 'Aun no participas oficialmente. Sin confirmacion, tus puntos no contaran y no aparecereas en el ranking.',
|
||||||
|
'dash_notice_unconfirmed_title' => 'Participacion aun no confirmada',
|
||||||
|
'dash_notice_unconfirmed_body' => 'Tus puntos ya se estan registrando, pero sin aceptar las condiciones aparecereas de forma anonima en el ranking y no podras ganar.',
|
||||||
|
'dash_notice_btn' => 'Confirmar participacion ahora',
|
||||||
|
'dash_modal_title' => 'Confirmar participacion',
|
||||||
|
'dash_modal_intro' => 'Por favor lee la informacion y las condiciones con atencion y confirma tu participacion.',
|
||||||
|
'dash_modal_cancel' => 'Cerrar',
|
||||||
'pending_confirmation_banner' => 'Tus puntos ya cuentan en el periodo de calificacion. Confirma la participacion para que tu nombre sea visible en el ranking y puedas usar todas las funciones.',
|
'pending_confirmation_banner' => 'Tus puntos ya cuentan en el periodo de calificacion. Confirma la participacion para que tu nombre sea visible en el ranking y puedas usar todas las funciones.',
|
||||||
'details_requires_confirmation' => 'La vista detallada solo esta disponible despues de confirmar la participacion.',
|
'details_requires_confirmation' => 'La vista detallada solo esta disponible despues de confirmar la participacion.',
|
||||||
'participate_abo_hint' => 'Ya tienes al menos una suscripcion relevante (suscripcion de consultor activa o suscripcion de cliente en el periodo de calificacion). Al participar, los puntos se aplican de inmediato segun las reglas vigentes.',
|
'participate_abo_hint' => 'Ya tienes al menos una suscripcion relevante (suscripcion de consultor activa o suscripcion de cliente en el periodo de calificacion). Al participar, los puntos se aplican de inmediato segun las reglas vigentes.',
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,6 @@ return [
|
||||||
'my_abo' => 'Mi Suscripción',
|
'my_abo' => 'Mi Suscripción',
|
||||||
'my_subscriptions' => 'Mis Suscripciones',
|
'my_subscriptions' => 'Mis Suscripciones',
|
||||||
'team_customers' => 'Clientes del Equipo',
|
'team_customers' => 'Clientes del Equipo',
|
||||||
|
'payment_monitor' => 'Monitor de Pagos',
|
||||||
|
'payment_monitor_management' => 'Monitor de Pagos (Dir.)',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
6
resources/lang/es/pagination.php
Normal file
6
resources/lang/es/pagination.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'previous' => '« Anterior',
|
||||||
|
'next' => 'Siguiente »',
|
||||||
|
];
|
||||||
|
|
@ -155,12 +155,32 @@ return [
|
||||||
'payment_not_found' => 'Pago no encontrado',
|
'payment_not_found' => 'Pago no encontrado',
|
||||||
'payment_not_found_description' => 'No se pudo encontrar el pago con la referencia :reference. Por favor contáctenos si ya ha realizado el pago.',
|
'payment_not_found_description' => 'No se pudo encontrar el pago con la referencia :reference. Por favor contáctenos si ya ha realizado el pago.',
|
||||||
'payment_canceled' => 'Pago cancelado',
|
'payment_canceled' => 'Pago cancelado',
|
||||||
'payment_canceled_description' => 'El proceso de pago fue cancelado. Su pedido no fue procesado.',
|
'payment_canceled_description' => 'Ha cancelado el proceso de pago. Su pedido no fue procesado y no se realizó ningún cargo.',
|
||||||
'payment_error' => 'Error de pago',
|
'payment_canceled_hint' => 'Puede iniciar un nuevo intento de pago en cualquier momento.',
|
||||||
'payment_error_description' => 'Se produjo un error durante el procesamiento del pago. Su pedido no pudo completarse.',
|
'payment_error' => 'Pago fallido',
|
||||||
|
'payment_error_description' => 'Lamentablemente, el pago no pudo completarse.',
|
||||||
|
'payment_error_hint' => 'Por favor, revise sus datos de pago e inténtelo de nuevo — o elija otro método de pago.',
|
||||||
|
'payment_error_retry' => 'Intentar de nuevo',
|
||||||
|
'payment_error_code' => 'Código de error',
|
||||||
|
'payment_error_what_to_do' => '¿Qué puedo hacer?',
|
||||||
'payment_unknown_status' => 'Estado de pago desconocido',
|
'payment_unknown_status' => 'Estado de pago desconocido',
|
||||||
'payment_unknown_status_description' => 'No se pudo determinar el estado del pago. Por favor contáctenos para más información.',
|
'payment_unknown_status_description' => 'No se pudo determinar el estado del pago. Por favor contáctenos para más información.',
|
||||||
'contact_support_if_needed' => 'Si tiene alguna pregunta, por favor contacte a nuestro servicio de atención al cliente.',
|
'contact_support_if_needed' => 'Si tiene más preguntas, por favor contacte a nuestro servicio de atención al cliente.',
|
||||||
|
'try_again' => 'Intentar de nuevo',
|
||||||
|
'choose_different_payment' => 'Elegir otro método de pago',
|
||||||
|
'nothing_was_charged' => 'No se ha realizado ningún cargo en su cuenta.',
|
||||||
|
'payment_error_reasons' => [
|
||||||
|
'card_expired' => 'Su tarjeta ha caducado. Por favor use una tarjeta válida o elija otro método de pago.',
|
||||||
|
'card_blocked' => 'Su tarjeta está bloqueada. Por favor contacte a su banco o elija otro método de pago.',
|
||||||
|
'card_invalid' => 'Los datos de la tarjeta son inválidos. Por favor verifique su entrada.',
|
||||||
|
'card_declined' => 'Su banco rechazó el pago. Por favor contacte a su banco o elija otro método de pago.',
|
||||||
|
'insufficient_funds' => 'El límite de la tarjeta fue superado. Por favor contacte a su banco o elija otro método de pago.',
|
||||||
|
'cvv_invalid' => 'El código de seguridad (CVV) es incorrecto. Por favor verifique los 3 dígitos en el reverso de su tarjeta.',
|
||||||
|
'3ds_failed' => 'La autenticación 3D Secure falló. Por favor inténtelo de nuevo o elija otro método de pago.',
|
||||||
|
'timeout' => 'La conexión con el banco se interrumpió (tiempo de espera). Por favor inténtelo de nuevo en unos minutos.',
|
||||||
|
'fraud' => 'El pago fue rechazado por razones de seguridad. Por favor contacte a su banco.',
|
||||||
|
'general' => 'Por favor verifique sus datos de pago e inténtelo de nuevo. Si el problema persiste, elija otro método de pago.',
|
||||||
|
],
|
||||||
|
|
||||||
// DHL Packstation/Paketbox
|
// DHL Packstation/Paketbox
|
||||||
'packstation_delivery' => 'Entrega a Packstation/Paketbox',
|
'packstation_delivery' => 'Entrega a Packstation/Paketbox',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<div class="timeline">
|
||||||
|
@forelse($incident->activities as $activity)
|
||||||
|
<div class="timeline-item {{ $activity->type === 'status_change' ? 'timeline-item-secondary' : '' }}">
|
||||||
|
<div class="timeline-indicator bg-{{ match($activity->type) {
|
||||||
|
'status_change' => 'info',
|
||||||
|
'email' => 'primary',
|
||||||
|
'call' => 'success',
|
||||||
|
'ticket' => 'warning',
|
||||||
|
'provider_response' => 'secondary',
|
||||||
|
default => 'light border'
|
||||||
|
} }}">
|
||||||
|
<i class="ion {{ $activity->type_icon }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-light">{{ $activity->type_label }}</span>
|
||||||
|
<strong class="ml-1">{{ $activity->title }}</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap ml-2">
|
||||||
|
{{ $activity->created_at->format('d.m.Y H:i') }}
|
||||||
|
— {{ $activity->author }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
@if($activity->content)
|
||||||
|
<div class="mt-1 text-muted">{{ $activity->content }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-muted">Noch keine Aktivitäten.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timeline { position: relative; padding-left: 2.5rem; }
|
||||||
|
.timeline::before { content: ''; position: absolute; left: 1rem; top: 0; bottom: 0; width: 2px; background: #e9ecef; }
|
||||||
|
.timeline-item { position: relative; margin-bottom: 1.25rem; }
|
||||||
|
.timeline-indicator {
|
||||||
|
position: absolute; left: -2.5rem; width: 2rem; height: 2rem;
|
||||||
|
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.875rem; color: #fff;
|
||||||
|
}
|
||||||
|
.timeline-content { background: #f8f9fa; border-radius: 0.375rem; padding: 0.75rem 1rem; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<div class="modal fade" id="createIncidentModal" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Neuen Incident anlegen</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal">
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('admin.payment-dashboard.store') }}">
|
||||||
|
@csrf
|
||||||
|
<div class="modal-body">
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-8">
|
||||||
|
<label>Titel <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="title" class="form-control" value="{{ old('title') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Erkannt am <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" name="detected_at" class="form-control"
|
||||||
|
value="{{ old('detected_at', now()->format('Y-m-d\TH:i')) }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Anbieter <span class="text-danger">*</span></label>
|
||||||
|
<select name="provider" class="custom-select" required>
|
||||||
|
<option value="payone" {{ old('provider', 'payone') === 'payone' ? 'selected' : '' }}>PAYONE</option>
|
||||||
|
{{-- <option value="stripe" {{ old('provider') === 'stripe' ? 'selected' : '' }}>Stripe</option> --}}
|
||||||
|
<option value="paypal" {{ old('provider') === 'paypal' ? 'selected' : '' }}>PayPal</option>
|
||||||
|
{{-- <option value="mollie" {{ old('provider') === 'mollie' ? 'selected' : '' }}>Mollie</option> --}}
|
||||||
|
<option value="other" {{ old('provider') === 'other' ? 'selected' : '' }}>Sonstige</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Typ <span class="text-danger">*</span></label>
|
||||||
|
<select name="type" class="custom-select" required>
|
||||||
|
<option value="outage" {{ old('type') === 'outage' ? 'selected' : '' }}>Ausfall</option>
|
||||||
|
<option value="ipn_error" {{ old('type') === 'ipn_error' ? 'selected' : '' }}>IPN-Fehler</option>
|
||||||
|
<option value="payment_failure" {{ old('type', 'payment_failure') === 'payment_failure' ? 'selected' : '' }}>Zahlungsfehler</option>
|
||||||
|
<option value="slow_response" {{ old('type') === 'slow_response' ? 'selected' : '' }}>Langsame Antwort</option>
|
||||||
|
<option value="other" {{ old('type') === 'other' ? 'selected' : '' }}>Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Schwere <span class="text-danger">*</span></label>
|
||||||
|
<select name="severity" class="custom-select" required>
|
||||||
|
<option value="low" {{ old('severity') === 'low' ? 'selected' : '' }}>Niedrig</option>
|
||||||
|
<option value="medium" {{ old('severity', 'medium') === 'medium' ? 'selected' : '' }}>Mittel</option>
|
||||||
|
<option value="high" {{ old('severity') === 'high' ? 'selected' : '' }}>Hoch</option>
|
||||||
|
<option value="critical" {{ old('severity') === 'critical' ? 'selected' : '' }}>Kritisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Betroffene Bestellungen</label>
|
||||||
|
<input type="number" name="affected_orders" class="form-control" min="0"
|
||||||
|
value="{{ old('affected_orders', 0) }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Betroffener Umsatz (€)</label>
|
||||||
|
<input type="number" name="affected_revenue" class="form-control" min="0" step="0.01"
|
||||||
|
value="{{ old('affected_revenue', '0.00') }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label>Ticket-Nummer</label>
|
||||||
|
<input type="text" name="ticket_number" class="form-control"
|
||||||
|
value="{{ old('ticket_number') }}" placeholder="z.B. PAYONE-12345">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<textarea name="description" class="form-control" rows="3"
|
||||||
|
placeholder="Was ist passiert?">{{ old('description') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="ion ion-md-alert"></i> Incident anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Schwere</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Anbieter</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Erkannt</th>
|
||||||
|
<th>Dauer</th>
|
||||||
|
@if(isset($showActions) && $showActions)
|
||||||
|
<th></th>
|
||||||
|
@endif
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($incidents as $incident)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ $incident->severity_color }}">
|
||||||
|
{{ $incident->severity_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.show', $incident) }}">
|
||||||
|
{{ $incident->title }}
|
||||||
|
</a>
|
||||||
|
@if($incident->ticket_number)
|
||||||
|
<span class="text-muted small ml-1">#{{ $incident->ticket_number }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge-secondary">{{ $incident->provider_label }}</span></td>
|
||||||
|
<td>
|
||||||
|
<i class="ion {{ $incident->type_icon }}"></i>
|
||||||
|
{{ $incident->type_label }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ $incident->status_color }}">
|
||||||
|
{{ $incident->status_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">{{ $incident->detected_at->format('d.m.Y H:i') }}</td>
|
||||||
|
<td class="text-nowrap">{{ $incident->duration }}</td>
|
||||||
|
@if(isset($showActions) && $showActions)
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<form method="POST" action="{{ route('admin.payment-dashboard.status.update', $incident) }}" class="d-inline">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<select name="status" class="custom-select custom-select-sm" onchange="this.form.submit()" style="width:auto">
|
||||||
|
@foreach(['open' => 'Offen', 'in_progress' => 'In Bearb.', 'waiting_provider' => 'Wartet', 'resolved' => 'Gelöst', 'closed' => 'Geschl.'] as $value => $label)
|
||||||
|
<option value="{{ $value }}" {{ $incident->status === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.show', $incident) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="ion ion-md-open"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
@endif
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-3">Keine Incidents vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card {{ $stats['critical_open'] > 0 ? 'border-danger' : '' }}">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Offene Incidents</div>
|
||||||
|
<div class="display-4 font-weight-bold {{ $stats['open_incidents'] > 0 ? 'text-danger' : 'text-success' }}">
|
||||||
|
{{ $stats['open_incidents'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">In Bearbeitung</div>
|
||||||
|
<div class="display-4 font-weight-bold {{ $stats['in_progress'] > 0 ? 'text-warning' : 'text-muted' }}">
|
||||||
|
{{ $stats['in_progress'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">PAYONE (30 Tage)</div>
|
||||||
|
<div class="display-4 font-weight-bold text-info">
|
||||||
|
{{ $stats['payone_incidents_30d'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Gelöst (Monat)</div>
|
||||||
|
<div class="display-4 font-weight-bold text-success">
|
||||||
|
{{ $stats['resolved_this_month'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Erfolgsrate Zahlung</div>
|
||||||
|
<div class="display-4 font-weight-bold {{ $transactionStats['success_rate'] < 90 ? 'text-warning' : 'text-success' }}">
|
||||||
|
{{ $transactionStats['success_rate'] }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-2">
|
||||||
|
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-warning' : '' }}">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Fehlgeschlagen</div>
|
||||||
|
<div class="display-4 font-weight-bold {{ $transactionStats['failed'] > 0 ? 'text-danger' : 'text-success' }}">
|
||||||
|
{{ $transactionStats['failed'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
457
resources/views/admin/payment-dashboard/abandoned.blade.php
Normal file
457
resources/views/admin/payment-dashboard/abandoned.blade.php
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<strong>Abbruch-Analyse</strong>
|
||||||
|
<small class="text-muted ml-2">Nicht gestartete, abgebrochene und technisch fehlerhafte Zahlungen</small>
|
||||||
|
</div>
|
||||||
|
{{-- Zeitraum-Filter --}}
|
||||||
|
<form method="GET" class="form-inline">
|
||||||
|
<label class="mr-2 text-muted small">Zeitraum:</label>
|
||||||
|
<select name="days" class="form-control form-control-sm mr-2" onchange="this.form.submit()">
|
||||||
|
@foreach ([7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 60 => '60 Tage', 90 => '90 Tage', 0 => 'Alle'] as $value => $label)
|
||||||
|
<option value="{{ $value }}" {{ $days == $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stat-Karten --}}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="text-muted small mb-1">Zahlung nie gestartet</div>
|
||||||
|
<div class="h3 font-weight-bold mb-0 text-warning">{{ $abandonedStats['no_payment'] }}</div>
|
||||||
|
<small class="text-muted">Orders mit txaction=prev ohne Payment</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="text-muted small mb-1">Abgebrochen / Fehler</div>
|
||||||
|
<div class="h3 font-weight-bold mb-0 text-danger">{{ $abandonedStats['cancelled'] }}</div>
|
||||||
|
<small class="text-muted">cancel + error Payments</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-secondary">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="text-muted small mb-1">Kein PAYONE-Callback</div>
|
||||||
|
<div class="h3 font-weight-bold mb-0 text-secondary">{{ $abandonedStats['no_callback'] }}</div>
|
||||||
|
<small class="text-muted">Payments ohne Transaktion (>2h)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tabs --}}
|
||||||
|
<ul class="nav nav-tabs mb-0" id="abandonedTabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" id="tab-no-payment" data-toggle="tab" href="#no-payment" role="tab">
|
||||||
|
Nie gestartet
|
||||||
|
@if ($abandonedStats['no_payment'] > 0)
|
||||||
|
<span class="badge badge-warning ml-1">{{ $abandonedStats['no_payment'] }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="tab-cancelled" data-toggle="tab" href="#cancelled" role="tab">
|
||||||
|
Abgebrochen / Fehler
|
||||||
|
@if ($abandonedStats['cancelled'] > 0)
|
||||||
|
<span class="badge badge-danger ml-1">{{ $abandonedStats['cancelled'] }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" id="tab-no-callback" data-toggle="tab" href="#no-callback" role="tab">
|
||||||
|
Kein Callback
|
||||||
|
@if ($abandonedStats['no_callback'] > 0)
|
||||||
|
<span class="badge badge-secondary ml-1">{{ $abandonedStats['no_callback'] }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content border border-top-0 rounded-bottom bg-white px-3 pt-3 pb-2 mb-4">
|
||||||
|
|
||||||
|
{{-- Tab 1: Orders ohne Payment --}}
|
||||||
|
<div class="tab-pane fade show active" id="no-payment" role="tabpanel">
|
||||||
|
<p class="text-muted small mt-1 mb-3">
|
||||||
|
Bestellungen, bei denen der Benutzer den Checkout-Prozess zwar abgeschlossen hat (txaction=prev),
|
||||||
|
aber die Zahlung nie initiiert wurde. Mindestens 30 Minuten alt.
|
||||||
|
</p>
|
||||||
|
@if ($ordersWithoutPayment->isEmpty())
|
||||||
|
<div class="alert alert-success">Keine offenen Bestellungen ohne Zahlung im gewählten Zeitraum.</div>
|
||||||
|
@else
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Order-ID</th>
|
||||||
|
<th>Kunde / Berater</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th>Vor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($ordersWithoutPayment as $order)
|
||||||
|
@php
|
||||||
|
$isConsultant = $order->auth_user_id && $order->auth_user;
|
||||||
|
if ($isConsultant) {
|
||||||
|
$name = trim(
|
||||||
|
($order->auth_user->firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->auth_user->lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->auth_user->email ?? '–';
|
||||||
|
} else {
|
||||||
|
$name = trim(
|
||||||
|
($order->shopping_user->billing_firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->shopping_user->billing_lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->shopping_user->billing_email ?? '–';
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ $order->auth_user_id ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
|
||||||
|
target="_blank" class="text-monospace">
|
||||||
|
#{{ $order->id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if ($isConsultant)
|
||||||
|
<span class="badge badge-primary badge-sm mr-1">Berater</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-info badge-sm mr-1">Kunde</span>
|
||||||
|
@endif
|
||||||
|
<strong>{{ $name ?: '–' }}</strong>
|
||||||
|
<br><small class="text-muted">{{ $email }}</small>
|
||||||
|
</td>
|
||||||
|
<td><small>{{ $order->payment_for ?? '–' }}</small></td>
|
||||||
|
<td class="font-weight-bold">
|
||||||
|
{{ $order->price_total ? number_format($order->price_total, 2, ',', '.') . ' €' : '–' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $order->created_at ? $order->created_at->format('d.m.Y H:i') : '–' }}</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $order->created_at ? $order->created_at->diffForHumans() : '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ $ordersWithoutPayment->links() }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab 2: Abgebrochene / Fehler --}}
|
||||||
|
<div class="tab-pane fade" id="cancelled" role="tabpanel">
|
||||||
|
<p class="text-muted small mt-1 mb-3">
|
||||||
|
Zahlungen, bei denen der Nutzer aktiv abgebrochen hat (<code>cancel</code>) oder bei denen PAYONE
|
||||||
|
einen Fehler zurückgemeldet hat (<code>error</code>). Zeile anklicken für PAYONE-Fehlerdetails.
|
||||||
|
</p>
|
||||||
|
@if ($cancelledPayments->isEmpty())
|
||||||
|
<div class="alert alert-success">Keine abgebrochenen Zahlungen im gewählten Zeitraum.</div>
|
||||||
|
@else
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:1rem"></th>
|
||||||
|
<th>Referenz</th>
|
||||||
|
<th>Order-ID</th>
|
||||||
|
<th>Kunde / Berater</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Zahlungsart</th>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Vor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($cancelledPayments as $payment)
|
||||||
|
@php
|
||||||
|
$order = $payment->shopping_order;
|
||||||
|
$isConsultant = $order && $order->auth_user_id && $order->auth_user;
|
||||||
|
if ($order && $isConsultant) {
|
||||||
|
$name = trim(
|
||||||
|
($order->auth_user->firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->auth_user->lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->auth_user->email ?? '–';
|
||||||
|
} elseif ($order && $order->shopping_user) {
|
||||||
|
$name = trim(
|
||||||
|
($order->shopping_user->billing_firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->shopping_user->billing_lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->shopping_user->billing_email ?? '–';
|
||||||
|
} else {
|
||||||
|
$name = '–';
|
||||||
|
$email = '–';
|
||||||
|
}
|
||||||
|
$hasTransactions = $payment->payment_transactions->isNotEmpty();
|
||||||
|
$collapseId = 'cancelled-tx-' . $payment->id;
|
||||||
|
@endphp
|
||||||
|
{{-- Hauptzeile --}}
|
||||||
|
<tr class="{{ $hasTransactions ? 'cursor-pointer' : '' }}"
|
||||||
|
@if($hasTransactions) data-toggle="collapse" data-target="#{{ $collapseId }}" @endif>
|
||||||
|
<td class="text-center text-muted">
|
||||||
|
@if($hasTransactions)
|
||||||
|
<i class="ion ion-md-chevron-forward toggle-icon" style="font-size:0.85rem"></i>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-monospace small">{{ $payment->reference }}</td>
|
||||||
|
<td>
|
||||||
|
@if ($order)
|
||||||
|
<a href="{{ $isConsultant ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
|
||||||
|
target="_blank" onclick="event.stopPropagation()">#{{ $order->id }}</a>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if ($order)
|
||||||
|
@if ($isConsultant)
|
||||||
|
<span class="badge badge-primary badge-sm mr-1">Berater</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-info badge-sm mr-1">Kunde</span>
|
||||||
|
@endif
|
||||||
|
<strong>{{ $name ?: '–' }}</strong>
|
||||||
|
<br><small class="text-muted">{{ $email }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="font-weight-bold">
|
||||||
|
{{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '–' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if ($payment->status === 'cancel')
|
||||||
|
<span class="badge badge-warning">Abgebrochen</span>
|
||||||
|
@elseif($payment->status === 'error')
|
||||||
|
<span class="badge badge-danger">Fehler</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-secondary">{{ $payment->status }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ $payment->payment_type ?? '–' }}</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '–' }}</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $payment->created_at ? $payment->created_at->diffForHumans() : '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Aufklappbare Fehlerdetails --}}
|
||||||
|
@if($hasTransactions)
|
||||||
|
<tr class="collapse" id="{{ $collapseId }}">
|
||||||
|
<td colspan="9" class="p-0">
|
||||||
|
<div class="bg-light border-top border-bottom px-3 py-2">
|
||||||
|
<small class="text-muted font-weight-bold d-block mb-2">
|
||||||
|
PAYONE-Transaktionen ({{ $payment->payment_transactions->count() }})
|
||||||
|
</small>
|
||||||
|
@foreach($payment->payment_transactions as $tx)
|
||||||
|
<div class="card card-body p-2 mb-2 {{ $tx->errorcode ? 'border-danger' : 'border-secondary' }}" style="font-size:0.78rem">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<span class="text-muted">TX-ID:</span>
|
||||||
|
<strong>{{ $tx->txid ?? '–' }}</strong><br>
|
||||||
|
<span class="text-muted">Action:</span>
|
||||||
|
<code>{{ $tx->txaction ?? '–' }}</code><br>
|
||||||
|
<span class="text-muted">Request:</span>
|
||||||
|
<code>{{ $tx->request ?? '–' }}</code><br>
|
||||||
|
<span class="text-muted">Status:</span>
|
||||||
|
@if($tx->status === 'approved')
|
||||||
|
<span class="badge badge-success">approved</span>
|
||||||
|
@elseif($tx->status === 'error')
|
||||||
|
<span class="badge badge-danger">error</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-secondary">{{ $tx->status ?? '–' }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
@php
|
||||||
|
$errorcode = $tx->errorcode
|
||||||
|
?? ($tx->transmitted_data['errorcode'] ?? null);
|
||||||
|
$failedcause = $tx->transmitted_data['failedcause'] ?? null;
|
||||||
|
$errormessage = $tx->errormessage
|
||||||
|
?? ($tx->transmitted_data['errormessage'] ?? null);
|
||||||
|
$customermessage = $tx->customermessage
|
||||||
|
?? ($tx->transmitted_data['customermessage'] ?? null);
|
||||||
|
$description = $tx->error_description;
|
||||||
|
@endphp
|
||||||
|
@if($errorcode)
|
||||||
|
<span class="text-danger font-weight-bold">
|
||||||
|
<i class="ion ion-md-warning"></i>
|
||||||
|
Fehlercode {{ $errorcode }}
|
||||||
|
</span><br>
|
||||||
|
@if($description)
|
||||||
|
<span class="font-weight-bold text-dark">{{ $description }}</span><br>
|
||||||
|
@endif
|
||||||
|
@if($errormessage)
|
||||||
|
<span class="text-muted">PAYONE-Meldung:</span>
|
||||||
|
<span class="text-danger">{{ $errormessage }}</span><br>
|
||||||
|
@endif
|
||||||
|
@if($failedcause && $failedcause != '-'.$errorcode)
|
||||||
|
<span class="text-muted">Ursache:</span>
|
||||||
|
<code>{{ $failedcause }}</code><br>
|
||||||
|
@endif
|
||||||
|
@if($customermessage)
|
||||||
|
<span class="text-muted">Kundennachricht:</span>
|
||||||
|
<em>{{ $customermessage }}</em>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted font-italic">
|
||||||
|
@if($tx->txaction === 'failed')
|
||||||
|
Fehlercode nicht übermittelt
|
||||||
|
<br><small>(txaction=failed ohne Fehlercode)</small>
|
||||||
|
@elseif($tx->status === 'REDIRECT')
|
||||||
|
Nutzer zu PAYONE weitergeleitet
|
||||||
|
<br><small>(kein Fehler, Redirect)</small>
|
||||||
|
@else
|
||||||
|
Kein Fehlercode in diesem Callback
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<span class="text-muted">Modus:</span>
|
||||||
|
@if($tx->mode === 'test')
|
||||||
|
<span class="badge badge-warning">TEST</span>
|
||||||
|
@elseif($tx->mode === 'live')
|
||||||
|
<span class="badge badge-success">LIVE</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
<br>
|
||||||
|
<span class="text-muted">Zeitpunkt:</span>
|
||||||
|
{{ $tx->created_at ? $tx->created_at->format('d.m.Y H:i:s') : '–' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ $cancelledPayments->links() }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab 3: Kein Callback --}}
|
||||||
|
<div class="tab-pane fade" id="no-callback" role="tabpanel">
|
||||||
|
<p class="text-muted small mt-1 mb-3">
|
||||||
|
Zahlungen, die gestartet wurden (PAYONE-Redirect), aber nach mehr als 2 Stunden
|
||||||
|
weder einen Callback noch eine Nutzer-Rückkehr registriert haben.
|
||||||
|
Dies kann auf technische Probleme (Timeout, fehlgeschlagene Weiterleitung) hinweisen.
|
||||||
|
</p>
|
||||||
|
@if ($pendingPayments->isEmpty())
|
||||||
|
<div class="alert alert-success">Keine offenen Zahlungen ohne Callback im gewählten Zeitraum.</div>
|
||||||
|
@else
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Referenz</th>
|
||||||
|
<th>Order-ID</th>
|
||||||
|
<th>Kunde / Berater</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Zahlungsart</th>
|
||||||
|
<th>Modus</th>
|
||||||
|
<th>Gestartet</th>
|
||||||
|
<th>Vor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($pendingPayments as $payment)
|
||||||
|
@php
|
||||||
|
$order = $payment->shopping_order;
|
||||||
|
$isConsultant = $order && $order->auth_user_id && $order->auth_user;
|
||||||
|
if ($order && $isConsultant) {
|
||||||
|
$name = trim(
|
||||||
|
($order->auth_user->firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->auth_user->lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->auth_user->email ?? '–';
|
||||||
|
} elseif ($order && $order->shopping_user) {
|
||||||
|
$name = trim(
|
||||||
|
($order->shopping_user->billing_firstname ?? '') .
|
||||||
|
' ' .
|
||||||
|
($order->shopping_user->billing_lastname ?? ''),
|
||||||
|
);
|
||||||
|
$email = $order->shopping_user->billing_email ?? '–';
|
||||||
|
} else {
|
||||||
|
$name = '–';
|
||||||
|
$email = '–';
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="text-monospace small">{{ $payment->reference }}</td>
|
||||||
|
<td>
|
||||||
|
@if ($order)
|
||||||
|
<a href="{{ $isConsultant ? route('admin_sales_users_detail', $order->id) : route('admin_sales_customers_detail', $order->id) }}"
|
||||||
|
target="_blank">#{{ $order->id }}</a>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if ($order)
|
||||||
|
@if ($isConsultant)
|
||||||
|
<span class="badge badge-primary badge-sm mr-1">Berater</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-info badge-sm mr-1">Kunde</span>
|
||||||
|
@endif
|
||||||
|
<strong>{{ $name ?: '–' }}</strong>
|
||||||
|
<br><small class="text-muted">{{ $email }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="font-weight-bold">
|
||||||
|
{{ $payment->amount ? number_format($payment->amount / 100, 2, ',', '.') . ' €' : '–' }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ $payment->payment_type ?? '–' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (($payment->mode ?? '') === 'test')
|
||||||
|
<span class="badge badge-warning">TEST</span>
|
||||||
|
@elseif(($payment->mode ?? '') === 'live')
|
||||||
|
<span class="badge badge-success">LIVE</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $payment->created_at ? $payment->created_at->format('d.m.Y H:i') : '–' }}</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $payment->created_at ? $payment->created_at->diffForHumans() : '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ $pendingPayments->links() }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
302
resources/views/admin/payment-dashboard/funnel.blade.php
Normal file
302
resources/views/admin/payment-dashboard/funnel.blade.php
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
|
||||||
|
<div class="mb-2">
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<strong>Checkout-Funnel Tracking</strong>
|
||||||
|
<small class="text-muted ml-2">Internes Tracking aller Checkout-Schritte</small>
|
||||||
|
</div>
|
||||||
|
<form method="GET" class="form-inline">
|
||||||
|
<label class="mr-2 text-muted small">Zeitraum:</label>
|
||||||
|
<select name="days" class="form-control form-control-sm" onchange="this.form.submit()">
|
||||||
|
@foreach ([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 60 => '60 Tage', 90 => '90 Tage', 0 => 'Alle'] as $value => $label)
|
||||||
|
<option value="{{ $value }}" {{ $days == $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php $topCount = $funnelSteps[0]['count'] > 0 ? $funnelSteps[0]['count'] : 1; @endphp
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{{-- ── Funnel ──────────────────────────────────────────────────────── --}}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><strong>Checkout-Funnel</strong></div>
|
||||||
|
<div class="card-body">
|
||||||
|
@foreach ($funnelSteps as $i => $step)
|
||||||
|
@php $barWidth = $topCount > 0 ? round($step['count'] / $topCount * 100) : 0; @endphp
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-secondary mr-1">{{ $i + 1 }}</span>
|
||||||
|
<strong>{{ $step['label'] }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="font-weight-bold">{{ number_format($step['count'], 0, ',', '.') }}</span>
|
||||||
|
@if ($step['conversion'] !== null)
|
||||||
|
<span
|
||||||
|
class="ml-2 small {{ $step['conversion'] >= 70 ? 'text-success' : ($step['conversion'] >= 40 ? 'text-warning' : 'text-danger') }}">
|
||||||
|
↓ {{ $step['conversion'] }}%
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:20px;">
|
||||||
|
<div class="progress-bar {{ $i === 0 ? 'bg-primary' : ($i === count($funnelSteps) - 1 ? 'bg-success' : 'bg-info') }}"
|
||||||
|
style="width:{{ max($barWidth, $step['count'] > 0 ? 2 : 0) }}%" role="progressbar">
|
||||||
|
@if ($barWidth > 8)
|
||||||
|
{{ $barWidth }}%
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@php
|
||||||
|
$totalConversion =
|
||||||
|
$funnelSteps[0]['count'] > 0
|
||||||
|
? round(($funnelSteps[4]['count'] / $funnelSteps[0]['count']) * 100, 1)
|
||||||
|
: 0;
|
||||||
|
@endphp
|
||||||
|
<div
|
||||||
|
class="alert {{ $totalConversion >= 50 ? 'alert-success' : ($totalConversion >= 25 ? 'alert-warning' : 'alert-danger') }} mt-2 mb-0 py-2">
|
||||||
|
<strong>Gesamt-Konversionsrate: {{ $totalConversion }}%</strong>
|
||||||
|
<small class="text-muted ml-2">(Checkout aufgerufen → Zahlung bestätigt)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Rechte Spalte ────────────────────────────────────────────────── --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
|
||||||
|
{{-- Rückkehr-Status --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><strong>Rückkehr von PAYONE</strong></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tbody>
|
||||||
|
@forelse($returnStats as $status => $count)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if ($status === 'success')
|
||||||
|
<span class="badge badge-success">success</span>
|
||||||
|
@elseif($status === 'cancel')
|
||||||
|
<span class="badge badge-warning">cancel</span>
|
||||||
|
@elseif($status === 'error')
|
||||||
|
<span class="badge badge-danger">error</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-secondary">{{ $status ?? '?' }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ number_format($count, 0, ',', '.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-muted text-center py-3 small">Noch keine Daten</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Quell-Kanal --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><strong>Quelle (Checkout-Aufrufe)</strong></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tbody>
|
||||||
|
@forelse($sourceBreakdown as $key => $source)
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:0.82rem">
|
||||||
|
@if ($key === 'kundenshop')
|
||||||
|
<i class="ion ion-md-cart text-success mr-1"></i>
|
||||||
|
@elseif($key === 'salescenter')
|
||||||
|
<i class="ion ion-md-briefcase text-primary mr-1"></i>
|
||||||
|
@elseif($key === 'beraterzugang')
|
||||||
|
<i class="ion ion-md-person text-info mr-1"></i>
|
||||||
|
@elseif($key === 'testserver')
|
||||||
|
<i class="ion ion-md-flask text-warning mr-1"></i>
|
||||||
|
@else
|
||||||
|
<i class="ion ion-md-help text-muted mr-1"></i>
|
||||||
|
@endif
|
||||||
|
{{ $source['label'] }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-weight-bold">
|
||||||
|
{{ number_format($source['count'], 0, ',', '.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-muted text-center py-3 small">Noch keine Daten</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Ereignisse (gefiltert + paginiert) ─────────────────────────────────── --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
||||||
|
<strong>Ereignisse</strong>
|
||||||
|
{{-- Filter-Leiste --}}
|
||||||
|
<form method="GET" class="form-inline mt-1 mt-md-0">
|
||||||
|
<input type="hidden" name="days" value="{{ $days }}">
|
||||||
|
<select name="event" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle Ereignisse</option>
|
||||||
|
@foreach (\App\Models\CheckoutFunnelEvent::eventLabels() as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ $filterEvent == $val ? 'selected' : '' }}>
|
||||||
|
{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select name="return_status" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
<option value="success" {{ $filterStatus == 'success' ? 'selected' : '' }}>success</option>
|
||||||
|
<option value="cancel" {{ $filterStatus == 'cancel' ? 'selected' : '' }}>cancel</option>
|
||||||
|
<option value="error" {{ $filterStatus == 'error' ? 'selected' : '' }}>error</option>
|
||||||
|
</select>
|
||||||
|
<select name="source" class="form-control form-control-sm mr-1" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle Quellen</option>
|
||||||
|
@foreach (\App\Models\CheckoutFunnelEvent::sourceLabels() as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ $filterSource == $val ? 'selected' : '' }}>
|
||||||
|
{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@if ($filterEvent || $filterStatus || $filterSource)
|
||||||
|
<a href="{{ route('admin.payment-dashboard.funnel', ['days' => $days]) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">✕ Reset</a>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Ereignis</th>
|
||||||
|
<th>Quelle</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Berater</th>
|
||||||
|
<th>Order-ID</th>
|
||||||
|
<th>Zahlungsart</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($recentEvents as $event)
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small text-nowrap">
|
||||||
|
{{ $event->created_at->format('d.m. H:i:s') }}<br>
|
||||||
|
<small>{{ $event->created_at->diffForHumans() }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@php
|
||||||
|
$badgeClass = match ($event->event) {
|
||||||
|
'checkout_visited' => 'badge-secondary',
|
||||||
|
'form_submitted' => 'badge-info',
|
||||||
|
'payment_initiated' => 'badge-primary',
|
||||||
|
'payment_returned' => match ($event->return_status) {
|
||||||
|
'success' => 'badge-success',
|
||||||
|
'cancel' => 'badge-warning',
|
||||||
|
default => 'badge-danger',
|
||||||
|
},
|
||||||
|
'payment_confirmed' => 'badge-success',
|
||||||
|
default => 'badge-light',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge {{ $badgeClass }}">{{ $event->event_label }}</span>
|
||||||
|
@if ($event->metadata && isset($event->metadata['txaction']))
|
||||||
|
<small class="text-muted ml-1">{{ $event->metadata['txaction'] }}</small>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
@php $src = $event->source_type; @endphp
|
||||||
|
@if ($src === 'kundenshop')
|
||||||
|
<span class="badge badge-success" title="Kundenshop">Shop</span>
|
||||||
|
@elseif($src === 'salescenter')
|
||||||
|
<span class="badge badge-primary" title="Salescenter">SC</span>
|
||||||
|
@elseif($src === 'beraterzugang')
|
||||||
|
<span class="badge badge-info" title="Beraterzugang">BZ</span>
|
||||||
|
@elseif($src === 'testserver')
|
||||||
|
<span class="badge badge-warning" title="Testserver">TEST</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-secondary">?</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ $event->domain ?? '–' }}</td>
|
||||||
|
<td class="small">
|
||||||
|
@if ($event->consultant)
|
||||||
|
{{ $event->consultant->firstname }} {{ $event->consultant->lastname }}
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
@if ($event->shopping_order_id)
|
||||||
|
<a href="{{ route('admin_sales_users_detail', $event->shopping_order_id) }}"
|
||||||
|
target="_blank">
|
||||||
|
#{{ $event->shopping_order_id }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ $event->payment_method ?? '–' }}</td>
|
||||||
|
<td class="small">
|
||||||
|
{{ $event->amount_cents ? number_format($event->amount_cents / 100, 2, ',', '.') . ' €' : '–' }}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
@if ($event->return_status)
|
||||||
|
@if ($event->return_status === 'success')
|
||||||
|
<span class="badge badge-success">{{ $event->return_status }}</span>
|
||||||
|
@elseif($event->return_status === 'cancel')
|
||||||
|
<span class="badge badge-warning">{{ $event->return_status }}</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-danger">{{ $event->return_status }}</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">–</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center text-muted py-4">
|
||||||
|
Keine Ereignisse gefunden.
|
||||||
|
@if ($filterEvent || $filterStatus || $filterSource)
|
||||||
|
<a href="{{ route('admin.payment-dashboard.funnel', ['days' => $days]) }}">Filter
|
||||||
|
zurücksetzen</a>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if ($recentEvents->hasPages())
|
||||||
|
<div class="card-footer">
|
||||||
|
{{ $recentEvents->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tracking-Hinweis --}}
|
||||||
|
<div class="alert alert-info small py-2">
|
||||||
|
<i class="ion ion-md-information-circle mr-1"></i>
|
||||||
|
<strong>Hinweis:</strong> Das Tracking ist ab dem Aktivierungszeitpunkt aktiv. Ältere Checkouts sind nicht
|
||||||
|
enthalten.
|
||||||
|
Schritt 5 „PAYONE Callback" wird sowohl bei synchroner Bestätigung (<code>transactionApproved</code>)
|
||||||
|
als auch bei asynchronem IPN-Callback (<code>txaction=paid</code> und <code>appointed</code>) erfasst.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
143
resources/views/admin/payment-dashboard/index.blade.php
Normal file
143
resources/views/admin/payment-dashboard/index.blade.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show">
|
||||||
|
{{ session('success') }}
|
||||||
|
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="ion ion-md-alert text-danger"></i> Payment Monitor
|
||||||
|
</h4>
|
||||||
|
<small class="text-muted">Entwickler-Ansicht — {{ now()->format('d.m.Y H:i') }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" data-toggle="modal" data-target="#createIncidentModal">
|
||||||
|
<i class="ion ion-md-add"></i> Neuer Incident
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stat-Karten --}}
|
||||||
|
@include('admin.payment-dashboard._partials.stats-cards')
|
||||||
|
|
||||||
|
{{-- Anbieter-Status --}}
|
||||||
|
<div class="row mb-4">
|
||||||
|
@foreach($providerStats as $key => $provider)
|
||||||
|
@php $uptime = $uptimeStats[$key] ?? null; @endphp
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card {{ $provider['open_incidents'] > 0 ? 'border-danger' : '' }}">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ $provider['label'] }}</strong>
|
||||||
|
<div>
|
||||||
|
@if($uptime && $uptime['last_check'])
|
||||||
|
@if($uptime['last_check']->is_up)
|
||||||
|
<span class="badge badge-success mr-1" title="Letzte Prüfung: {{ $uptime['last_check']->checked_at->diffForHumans() }}">
|
||||||
|
<i class="ion ion-md-checkmark"></i> Online
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-danger mr-1" title="{{ $uptime['last_check']->error_message }}">
|
||||||
|
<i class="ion ion-md-close"></i> Offline
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@if($provider['open_incidents'] > 0)
|
||||||
|
<span class="badge badge-warning">{{ $provider['open_incidents'] }} Incident</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">{{ $provider['total_30d'] }} Incidents (30 Tage)</div>
|
||||||
|
@if($uptime && $uptime['checks_24h'] > 0)
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">
|
||||||
|
Uptime 24h: {{ $uptime['uptime_24h'] }}%
|
||||||
|
@if($uptime['failures_24h'] > 0)
|
||||||
|
— <span class="text-danger">{{ $uptime['failures_24h'] }} Ausfälle</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif($uptime)
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">Noch keine Uptime-Daten</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tabs --}}
|
||||||
|
<ul class="nav nav-tabs" id="dashboardTabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-toggle="tab" href="#tab-open">
|
||||||
|
Offene Incidents
|
||||||
|
@if($openIncidents->count() > 0)
|
||||||
|
<span class="badge badge-danger ml-1">{{ $openIncidents->count() }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-toggle="tab" href="#tab-all">Alle Incidents</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ route('admin.payment-dashboard.payments') }}">
|
||||||
|
<i class="ion ion-md-card"></i> Zahlungen & Transaktionen
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ route('admin.payment-dashboard.transactions') }}">
|
||||||
|
<i class="ion ion-md-swap"></i> Rohe Transaktionen
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ route('admin.payment-dashboard.funnel') }}">
|
||||||
|
<i class="ion ion-md-funnel"></i> Funnel-Tracking
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ route('admin.payment-dashboard.abandoned') }}">
|
||||||
|
<i class="ion ion-md-alert"></i> Abbruch-Analyse
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ route('admin.payment-dashboard.logs') }}">
|
||||||
|
<i class="ion ion-md-list"></i> PAYONE Logs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
{{-- Tab: Offene Incidents --}}
|
||||||
|
<div class="tab-pane fade show active pt-3" id="tab-open">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@include('admin.payment-dashboard._partials.incident-table', [
|
||||||
|
'incidents' => $openIncidents,
|
||||||
|
'showActions' => true,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab: Alle Incidents --}}
|
||||||
|
<div class="tab-pane fade pt-3" id="tab-all">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@include('admin.payment-dashboard._partials.incident-table', [
|
||||||
|
'incidents' => $allIncidents,
|
||||||
|
'showActions' => true,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ $allIncidents->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include('admin.payment-dashboard._partials.create-incident-modal')
|
||||||
|
|
||||||
|
@endsection
|
||||||
86
resources/views/admin/payment-dashboard/logs.blade.php
Normal file
86
resources/views/admin/payment-dashboard/logs.blade.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<strong>PAYONE Log-Viewer</strong>
|
||||||
|
</div>
|
||||||
|
@if(count($availableDates) > 1)
|
||||||
|
<form method="GET" action="{{ route('admin.payment-dashboard.logs') }}">
|
||||||
|
<select name="date" class="custom-select custom-select-sm" onchange="this.form.submit()">
|
||||||
|
@foreach($availableDates as $date)
|
||||||
|
<option value="{{ $date }}" {{ $selectedDate === $date ? 'selected' : '' }}>
|
||||||
|
{{ \Carbon\Carbon::parse($date)->format('d.m.Y') }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(count($entries) === 0)
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="ion ion-md-information-circle"></i>
|
||||||
|
Keine Log-Einträge für {{ \Carbon\Carbon::parse($selectedDate)->format('d.m.Y') }} gefunden.
|
||||||
|
@if(count($availableDates) > 0)
|
||||||
|
Verfügbare Daten: {{ implode(', ', array_map(fn($d) => \Carbon\Carbon::parse($d)->format('d.m.Y'), $availableDates)) }}
|
||||||
|
@else
|
||||||
|
Der Log-Kanal <code>payone</code> hat noch keine Einträge geschrieben.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mb-2 text-muted small">
|
||||||
|
<i class="ion ion-md-list"></i> {{ count($entries) }} Einträge (neueste zuerst)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="logFilter" class="form-control form-control-sm"
|
||||||
|
placeholder="Filter: Stichwort oder Fehlercode (z.B. Error:2003)..."
|
||||||
|
oninput="filterLogs(this.value)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="logEntries">
|
||||||
|
@foreach($entries as $entry)
|
||||||
|
@php
|
||||||
|
$levelColor = match($entry['level']) {
|
||||||
|
'error' => 'danger',
|
||||||
|
'warning' => 'warning',
|
||||||
|
'info' => 'info',
|
||||||
|
'notice' => 'secondary',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<div class="log-entry border-bottom px-3 py-2 {{ $entry['level'] === 'error' ? 'bg-light' : '' }}"
|
||||||
|
data-search="{{ strtolower($entry['timestamp'] . ' ' . $entry['level'] . ' ' . $entry['message']) }}">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<span class="badge badge-{{ $levelColor }} mr-2 mt-1 flex-shrink-0">{{ strtoupper($entry['level']) }}</span>
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="text-muted small">{{ $entry['timestamp'] }}</div>
|
||||||
|
<div class="small" style="word-break:break-all; font-family:monospace">{{ $entry['message'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterLogs(term) {
|
||||||
|
const entries = document.querySelectorAll('.log-entry');
|
||||||
|
const search = term.toLowerCase();
|
||||||
|
entries.forEach(el => {
|
||||||
|
el.style.display = el.dataset.search.includes(search) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@endsection
|
||||||
111
resources/views/admin/payment-dashboard/management.blade.php
Normal file
111
resources/views/admin/payment-dashboard/management.blade.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="ion ion-md-alert text-danger"></i> Payment Monitor
|
||||||
|
</h4>
|
||||||
|
<small class="text-muted">Stand: {{ now()->format('d.m.Y H:i') }}</small>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="ion ion-md-code-working"></i> Entwickler-Ansicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Ampel-Karten --}}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
@php
|
||||||
|
$level = $stats['critical_open'] > 0 ? 'danger' : ($stats['open_incidents'] > 0 ? 'warning' : 'success');
|
||||||
|
$levelText = $stats['critical_open'] > 0 ? 'Kritische Störung!' : ($stats['open_incidents'] > 0 ? 'Offene Störungen' : 'Alles in Ordnung');
|
||||||
|
@endphp
|
||||||
|
<div class="card border-{{ $level }}">
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<i class="ion ion-md-{{ $level === 'success' ? 'checkmark-circle' : 'alert' }} text-{{ $level }}" style="font-size: 3rem"></i>
|
||||||
|
<h5 class="mt-2 text-{{ $level }}">{{ $levelText }}</h5>
|
||||||
|
<div class="display-4 font-weight-bold">{{ $stats['open_incidents'] }}</div>
|
||||||
|
<div class="text-muted">Offene Störungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-warning' : '' }}">
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<i class="ion ion-md-card text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}" style="font-size: 3rem"></i>
|
||||||
|
<h5 class="mt-2">Zahlungsquote</h5>
|
||||||
|
<div class="display-4 font-weight-bold text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}">
|
||||||
|
{{ $transactionStats['success_rate'] }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
{{ $transactionStats['failed'] }} fehlgeschlagen (30 Tage)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
<i class="ion ion-md-cash text-info" style="font-size: 3rem"></i>
|
||||||
|
<h5 class="mt-2">Betroffener Umsatz</h5>
|
||||||
|
<div class="display-4 font-weight-bold text-{{ $stats['total_affected_revenue'] > 0 ? 'warning' : 'success' }}">
|
||||||
|
{{ number_format($stats['total_affected_revenue'], 0, ',', '.') }} €
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">bei offenen Incidents</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Anbieter-Status --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<h6 class="card-header">Anbieter-Status</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
@foreach($providerStats as $key => $provider)
|
||||||
|
<div class="col-sm-6 col-md-3 text-center mb-3">
|
||||||
|
<div class="h5 mb-1">{{ $provider['label'] }}</div>
|
||||||
|
@if($provider['open_incidents'] > 0)
|
||||||
|
<span class="badge badge-danger badge-pill" style="font-size:1rem; padding: 0.5rem 1rem">
|
||||||
|
<i class="ion ion-md-alert"></i> {{ $provider['open_incidents'] }} Störung(en)
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-success badge-pill" style="font-size:1rem; padding: 0.5rem 1rem">
|
||||||
|
<i class="ion ion-md-checkmark"></i> OK
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<div class="text-muted small mt-1">{{ $provider['total_30d'] }} Incidents (30d)</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Aktive Störungen --}}
|
||||||
|
@if($openIncidents->count() > 0)
|
||||||
|
<div class="card mb-4 border-danger">
|
||||||
|
<h6 class="card-header bg-danger text-white">
|
||||||
|
<i class="ion ion-md-alert"></i> Aktive Störungen ({{ $openIncidents->count() }})
|
||||||
|
</h6>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@include('admin.payment-dashboard._partials.incident-table', [
|
||||||
|
'incidents' => $openIncidents,
|
||||||
|
'showActions' => false,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Letzte Vorfälle --}}
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header">Letzte Vorfälle</h6>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@include('admin.payment-dashboard._partials.incident-table', [
|
||||||
|
'incidents' => $recentIncidents,
|
||||||
|
'showActions' => false,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
328
resources/views/admin/payment-dashboard/payments.blade.php
Normal file
328
resources/views/admin/payment-dashboard/payments.blade.php
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<strong>Zahlungs-Übersicht</strong>
|
||||||
|
<small class="text-muted ml-2">ShoppingPayments mit Transaktionen und Bestellung</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stat-Karten --}}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Zahlungen gesamt</div>
|
||||||
|
<div class="h4 font-weight-bold mb-0">{{ $paymentStats['total'] }}</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">{{ $paymentStats['days'] }} Tage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Bezahlt</div>
|
||||||
|
<div class="h4 font-weight-bold mb-0 text-success">{{ $paymentStats['paid'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card {{ $paymentStats['failed'] > 0 ? 'border-danger' : '' }}">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Mit Fehler</div>
|
||||||
|
<div class="h4 font-weight-bold mb-0 {{ $paymentStats['failed'] > 0 ? 'text-danger' : 'text-muted' }}">
|
||||||
|
{{ $paymentStats['failed'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Ausstehend</div>
|
||||||
|
<div class="h4 font-weight-bold mb-0 text-warning">{{ $paymentStats['pending'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Volumen gesamt</div>
|
||||||
|
<div class="h5 font-weight-bold mb-0">{{ number_format($paymentStats['total_amount'], 2, ',', '.') }} €</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-xl-2">
|
||||||
|
<div class="card {{ $paymentStats['failed_amount'] > 0 ? 'border-danger' : '' }}">
|
||||||
|
<div class="card-body text-center py-2">
|
||||||
|
<div class="text-muted small">Fehlvolumen</div>
|
||||||
|
<div class="h5 font-weight-bold mb-0 {{ $paymentStats['failed_amount'] > 0 ? 'text-danger' : 'text-muted' }}">
|
||||||
|
{{ number_format($paymentStats['failed_amount'], 2, ',', '.') }} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="GET" action="{{ route('admin.payment-dashboard.payments') }}" class="form-inline flex-wrap">
|
||||||
|
<label class="mr-2 small">Zeitraum:</label>
|
||||||
|
<select name="days" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
|
||||||
|
@foreach([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 0 => 'Alle'] as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<label class="mr-2 small">Status:</label>
|
||||||
|
<select name="txaction" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
@foreach(['paid' => 'Bezahlt', 'appointed' => 'Vorgemerkt', 'pending' => 'Ausstehend', 'failed' => 'Fehlgeschlagen'] as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ $statusFilter === $val ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<label class="mr-2 small">Modus:</label>
|
||||||
|
<select name="mode" class="custom-select custom-select-sm mr-3 mb-1" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="live" {{ $modeFilter === 'live' ? 'selected' : '' }}>Live</option>
|
||||||
|
<option value="test" {{ $modeFilter === 'test' ? 'selected' : '' }}>Test</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Zahlungs-Tabelle --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px"></th>
|
||||||
|
<th>Payment ID</th>
|
||||||
|
<th>Bestellung</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Zahlart</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Modus</th>
|
||||||
|
<th>Transaktionen</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($payments as $payment)
|
||||||
|
@php
|
||||||
|
$hasFailed = $payment->payment_transactions->where('txaction', 'failed')->count() > 0;
|
||||||
|
$isPaid = $payment->txaction === 'paid';
|
||||||
|
$rowClass = $hasFailed ? 'table-danger' : ($isPaid ? 'table-success' : '');
|
||||||
|
@endphp
|
||||||
|
<tr class="{{ $rowClass }}">
|
||||||
|
<td class="text-center align-middle">
|
||||||
|
@if($payment->payment_transactions->count() > 0)
|
||||||
|
<button class="btn btn-sm btn-link p-0 text-muted"
|
||||||
|
data-toggle="collapse"
|
||||||
|
data-target="#tx-{{ $payment->id }}"
|
||||||
|
title="{{ $payment->payment_transactions->count() }} Transaktionen">
|
||||||
|
<i class="ion ion-md-arrow-dropdown"></i>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<code class="small">{{ $payment->reference }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
@if($payment->shopping_order)
|
||||||
|
@php
|
||||||
|
$isCustomerOrder = in_array($payment->shopping_order->payment_for, [6, 7]);
|
||||||
|
$orderRoute = $isCustomerOrder
|
||||||
|
? route('admin_sales_customers_detail', $payment->shopping_order->id)
|
||||||
|
: route('admin_sales_users_detail', $payment->shopping_order->id);
|
||||||
|
@endphp
|
||||||
|
<a href="{{ $orderRoute }}" class="font-weight-bold" target="_blank">
|
||||||
|
#{{ $payment->shopping_order->id }}
|
||||||
|
</a>
|
||||||
|
@if($payment->shopping_order->paid)
|
||||||
|
<i class="ion ion-md-checkmark-circle text-success ml-1" title="Bezahlt"></i>
|
||||||
|
@endif
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">
|
||||||
|
{{ number_format($payment->shopping_order->total ?? 0, 2, ',', '.') }} €
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle small">
|
||||||
|
@if($payment->shopping_order?->auth_user_id && $payment->shopping_order->auth_user)
|
||||||
|
{{-- Berater-Bestellung --}}
|
||||||
|
<span class="badge badge-primary mb-1">Berater</span>
|
||||||
|
<div>{{ $payment->shopping_order->auth_user->name }}</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">
|
||||||
|
{{ $payment->shopping_order->auth_user->email }}
|
||||||
|
</div>
|
||||||
|
@elseif($payment->shopping_order?->shopping_user)
|
||||||
|
{{-- Kunden-Bestellung --}}
|
||||||
|
@php $su = $payment->shopping_order->shopping_user; @endphp
|
||||||
|
<span class="badge badge-info mb-1">Kunde</span>
|
||||||
|
<div>
|
||||||
|
{{ trim($su->billing_firstname . ' ' . $su->billing_lastname) ?: '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">
|
||||||
|
{{ $su->billing_email }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle small">
|
||||||
|
{{ $payment->getPaymentType() }}
|
||||||
|
@if($payment->clearingtype)
|
||||||
|
<span class="badge badge-secondary ml-1">{{ $payment->clearingtype }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-nowrap">
|
||||||
|
<strong>{{ number_format($payment->amount / 100, 2, ',', '.') }}</strong>
|
||||||
|
<small class="text-muted">{{ $payment->currency }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
@php
|
||||||
|
$txColor = match($payment->txaction) {
|
||||||
|
'paid' => 'success',
|
||||||
|
'failed' => 'danger',
|
||||||
|
'appointed' => 'info',
|
||||||
|
'pending' => 'warning',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge badge-{{ $txColor }}">{{ $payment->txaction ?? '—' }}</span>
|
||||||
|
@if($hasFailed && $isPaid)
|
||||||
|
<span class="badge badge-warning ml-1" title="Hatte fehlerhafte Transaktionen">
|
||||||
|
<i class="ion ion-md-warning"></i>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
@if($payment->mode)
|
||||||
|
<span class="badge badge-{{ $payment->mode === 'test' ? 'warning' : 'light' }}">
|
||||||
|
{{ $payment->mode }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-center">
|
||||||
|
@if($payment->payment_transactions->count() > 0)
|
||||||
|
<span class="badge badge-{{ $hasFailed ? 'danger' : 'secondary' }}">
|
||||||
|
{{ $payment->payment_transactions->count() }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">0</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-nowrap small text-muted">
|
||||||
|
{{ $payment->created_at->format('d.m.Y H:i') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{-- Aufklappbare Transaktions-Sub-Tabelle --}}
|
||||||
|
@if($payment->payment_transactions->count() > 0)
|
||||||
|
<tr class="collapse" id="tx-{{ $payment->id }}">
|
||||||
|
<td colspan="10" class="p-0">
|
||||||
|
<div class="bg-light border-bottom px-3 py-2">
|
||||||
|
<small class="font-weight-bold text-muted text-uppercase">
|
||||||
|
Transaktionen zu Payment #{{ $payment->id }} / Referenz {{ $payment->reference }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm table-bordered mb-0" style="background:#fafafa">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-light">
|
||||||
|
<th class="pl-4">TX-ID</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Fehlercode</th>
|
||||||
|
<th>Fehlermeldung</th>
|
||||||
|
<th>Kundennachricht</th>
|
||||||
|
<th>Modus</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($payment->payment_transactions as $tx)
|
||||||
|
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
|
||||||
|
<td class="pl-4 small">{{ $tx->txid ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
@php
|
||||||
|
$txaColor = match($tx->txaction) {
|
||||||
|
'paid' => 'success', 'failed' => 'danger',
|
||||||
|
'appointed' => 'info', 'pending' => 'warning',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge badge-{{ $txaColor }}">{{ $tx->txaction ?? '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ $tx->status ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->errorcode)
|
||||||
|
<span class="text-danger font-weight-bold">{{ $tx->errorcode }}</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted" style="max-width:200px">
|
||||||
|
{{ \Illuminate\Support\Str::limit($tx->errormessage, 60) }}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted" style="max-width:150px">
|
||||||
|
{{ \Illuminate\Support\Str::limit($tx->customermessage, 50) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->mode)
|
||||||
|
<span class="badge badge-{{ $tx->mode === 'test' ? 'warning' : 'light' }}">{{ $tx->mode }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap small text-muted">{{ $tx->created_at->format('d.m.Y H:i') }}</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->transmitted_data)
|
||||||
|
<button class="btn btn-xs btn-outline-secondary"
|
||||||
|
data-toggle="collapse"
|
||||||
|
data-target="#raw-{{ $tx->id }}"
|
||||||
|
title="Rohdaten">
|
||||||
|
<i class="ion ion-md-code"></i>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if($tx->transmitted_data)
|
||||||
|
<tr class="collapse" id="raw-{{ $tx->id }}">
|
||||||
|
<td colspan="9" class="bg-white">
|
||||||
|
<pre class="mb-0 small" style="max-height:150px; overflow-y:auto; font-size:0.75rem">{{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="text-center text-muted py-4">
|
||||||
|
Keine Zahlungen im gewählten Zeitraum gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ $payments->links() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
165
resources/views/admin/payment-dashboard/show.blade.php
Normal file
165
resources/views/admin/payment-dashboard/show.blade.php
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show">
|
||||||
|
{{ session('success') }}
|
||||||
|
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-{{ $incident->severity_color }} badge-pill px-3 py-2" style="font-size:0.9rem">
|
||||||
|
{{ $incident->severity_label }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-{{ $incident->status_color }} badge-pill px-3 py-2 ml-1" style="font-size:0.9rem">
|
||||||
|
{{ $incident->status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{{-- Linke Spalte: Details + Timeline --}}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<i class="ion {{ $incident->type_icon }} mr-2 text-secondary"></i>
|
||||||
|
<h5 class="mb-0">{{ $incident->title }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Anbieter</small>
|
||||||
|
<span class="badge badge-secondary">{{ $incident->provider_label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Typ</small>
|
||||||
|
{{ $incident->type_label }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Erkannt am</small>
|
||||||
|
{{ $incident->detected_at->format('d.m.Y H:i') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Dauer</small>
|
||||||
|
{{ $incident->duration }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Betroffene Bestellungen</small>
|
||||||
|
{{ $incident->affected_orders }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<small class="text-muted d-block">Betroffener Umsatz</small>
|
||||||
|
{{ number_format($incident->affected_revenue, 2, ',', '.') }} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($incident->ticket_number)
|
||||||
|
<div class="mb-3">
|
||||||
|
<small class="text-muted d-block">Ticket-Nummer</small>
|
||||||
|
<code>{{ $incident->ticket_number }}</code>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($incident->description)
|
||||||
|
<div class="mb-3">
|
||||||
|
<small class="text-muted d-block">Beschreibung</small>
|
||||||
|
<p class="mb-0">{{ $incident->description }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($incident->notes)
|
||||||
|
<div>
|
||||||
|
<small class="text-muted d-block">Interne Notizen</small>
|
||||||
|
<p class="mb-0 text-muted">{{ $incident->notes }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Aktivitäten-Timeline --}}
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header">Kommunikationsverlauf ({{ $incident->activities->count() }})</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
@include('admin.payment-dashboard._partials.activity-timeline')
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Rechte Spalte: Aktionen --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
{{-- Status ändern --}}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<h6 class="card-header">Status ändern</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ route('admin.payment-dashboard.status.update', $incident) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
<div class="form-group">
|
||||||
|
<select name="status" class="custom-select">
|
||||||
|
@foreach(['open' => 'Offen', 'in_progress' => 'In Bearbeitung', 'waiting_provider' => 'Wartet auf Anbieter', 'resolved' => 'Gelöst', 'closed' => 'Geschlossen'] as $value => $label)
|
||||||
|
<option value="{{ $value }}" {{ $incident->status === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Status speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Aktivität hinzufügen --}}
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header">Aktivität hinzufügen</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
@if($errors->has('type') || $errors->has('title'))
|
||||||
|
<div class="alert alert-danger small">
|
||||||
|
@foreach($errors->all() as $error)
|
||||||
|
<div>{{ $error }}</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<form method="POST" action="{{ route('admin.payment-dashboard.activity.store', $incident) }}">
|
||||||
|
@csrf
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="small">Typ</label>
|
||||||
|
<select name="type" class="custom-select custom-select-sm">
|
||||||
|
<option value="note">Notiz</option>
|
||||||
|
<option value="email">E-Mail</option>
|
||||||
|
<option value="call">Telefonat</option>
|
||||||
|
<option value="ticket">Ticket</option>
|
||||||
|
<option value="provider_response">Anbieter-Antwort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="small">Titel</label>
|
||||||
|
<input type="text" name="title" class="form-control form-control-sm"
|
||||||
|
placeholder="Kurze Beschreibung" value="{{ old('title') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="small">Details</label>
|
||||||
|
<textarea name="content" class="form-control form-control-sm" rows="3"
|
||||||
|
placeholder="Inhalt der Aktivität...">{{ old('content') }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-block btn-sm">
|
||||||
|
<i class="ion ion-md-add"></i> Aktivität speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($incident->resolved_at)
|
||||||
|
<div class="mt-3 text-center text-muted small">
|
||||||
|
<i class="ion ion-md-checkmark-circle text-success"></i>
|
||||||
|
Gelöst am {{ $incident->resolved_at->format('d.m.Y H:i') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
183
resources/views/admin/payment-dashboard/transactions.blade.php
Normal file
183
resources/views/admin/payment-dashboard/transactions.blade.php
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="btn btn-sm btn-outline-secondary mr-2">
|
||||||
|
<i class="ion ion-md-arrow-back"></i> Zurück
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.payment-dashboard.payments') }}" class="btn btn-sm btn-outline-primary mr-2">
|
||||||
|
<i class="ion ion-md-card"></i> Zahlungen & Transaktionen
|
||||||
|
</a>
|
||||||
|
<strong>Rohe Transaktionen</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stat-Karten --}}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Gesamt</div>
|
||||||
|
<div class="display-4 font-weight-bold">{{ $transactionStats['total'] }}</div>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem">letzte {{ $transactionStats['days'] }} Tage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Erfolgsrate</div>
|
||||||
|
<div class="display-4 font-weight-bold text-{{ $transactionStats['success_rate'] >= 95 ? 'success' : ($transactionStats['success_rate'] >= 80 ? 'warning' : 'danger') }}">
|
||||||
|
{{ $transactionStats['success_rate'] }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Bezahlt</div>
|
||||||
|
<div class="display-4 font-weight-bold text-success">{{ $transactionStats['paid'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-xl-3">
|
||||||
|
<div class="card {{ $transactionStats['failed'] > 0 ? 'border-danger' : '' }}">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="text-muted small">Fehlgeschlagen</div>
|
||||||
|
<div class="display-4 font-weight-bold {{ $transactionStats['failed'] > 0 ? 'text-danger' : 'text-muted' }}">
|
||||||
|
{{ $transactionStats['failed'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($transactionStats['error_distribution']->count() > 0)
|
||||||
|
<div class="card mb-4">
|
||||||
|
<h6 class="card-header">Fehlercodes (letzte {{ $transactionStats['days'] }} Tage)</h6>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@foreach($transactionStats['error_distribution'] as $error)
|
||||||
|
<span class="badge badge-danger mr-2 mb-1" style="font-size:0.85rem; padding: 0.4rem 0.7rem">
|
||||||
|
Code {{ $error->errorcode }}: {{ $error->count }}×
|
||||||
|
@if($error->errormessage) — {{ \Illuminate\Support\Str::limit($error->errormessage, 60) }} @endif
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="GET" action="{{ route('admin.payment-dashboard.transactions') }}" class="form-inline">
|
||||||
|
<label class="mr-2 small">Zeitraum:</label>
|
||||||
|
<select name="days" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
|
||||||
|
@foreach([1 => 'Heute', 7 => '7 Tage', 14 => '14 Tage', 30 => '30 Tage', 0 => 'Alle'] as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ (int)$days === $val ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<label class="mr-2 small">Aktion:</label>
|
||||||
|
<select name="txaction" class="custom-select custom-select-sm mr-3" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
@foreach(['paid' => 'Bezahlt', 'appointed' => 'Vorgemerkt', 'pending' => 'Ausstehend', 'failed' => 'Fehlgeschlagen'] as $val => $label)
|
||||||
|
<option value="{{ $val }}" {{ $txactionFilter == $val ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Transaktions-Tabelle --}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th>TX-ID</th>
|
||||||
|
<th>Referenz</th>
|
||||||
|
<th>Modus</th>
|
||||||
|
<th>Fehlercode</th>
|
||||||
|
<th>Fehlermeldung</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($transactions as $tx)
|
||||||
|
<tr class="{{ $tx->txaction === 'failed' ? 'table-danger' : ($tx->txaction === 'paid' ? 'table-success' : '') }}">
|
||||||
|
<td class="text-muted small">{{ $tx->id }}</td>
|
||||||
|
<td class="text-nowrap small">{{ $tx->created_at->format('d.m.Y H:i') }}</td>
|
||||||
|
<td>
|
||||||
|
@php
|
||||||
|
$actionColor = match($tx->txaction) {
|
||||||
|
'paid' => 'success',
|
||||||
|
'failed' => 'danger',
|
||||||
|
'appointed' => 'info',
|
||||||
|
'pending' => 'warning',
|
||||||
|
default => 'secondary',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge badge-{{ $actionColor }}">{{ $tx->txaction ?? '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ $tx->txid ?? '—' }}</td>
|
||||||
|
<td class="small">
|
||||||
|
@if($tx->shopping_payment)
|
||||||
|
<code>{{ $tx->shopping_payment->reference }}</code>
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->mode)
|
||||||
|
<span class="badge badge-{{ $tx->mode === 'test' ? 'warning' : 'secondary' }}">{{ $tx->mode }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->errorcode)
|
||||||
|
<span class="text-danger font-weight-bold">{{ $tx->errorcode }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||||
|
{{ $tx->errormessage }}
|
||||||
|
@if($tx->customermessage)
|
||||||
|
<span class="text-info">({{ \Illuminate\Support\Str::limit($tx->customermessage, 40) }})</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($tx->transmitted_data)
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||||
|
data-toggle="collapse" data-target="#tx-data-{{ $tx->id }}">
|
||||||
|
<i class="ion ion-md-code"></i>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if($tx->transmitted_data)
|
||||||
|
<tr class="collapse" id="tx-data-{{ $tx->id }}">
|
||||||
|
<td colspan="9" class="bg-light">
|
||||||
|
<pre class="mb-0 small" style="max-height:200px; overflow-y:auto">{{ json_encode($tx->transmitted_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center text-muted py-3">Keine Transaktionen gefunden.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{{ $transactions->links() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
@ -138,6 +138,148 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Umsätze nach Ländern - Jährlich -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Umsätze nach Ländern {{ session('revenue_filter_year') }}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if(isset($revenue_summary['country_yearly']) && $revenue_summary['country_yearly']->count() > 0)
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Land</th>
|
||||||
|
<th class="text-right">Netto</th>
|
||||||
|
<th class="text-right">Steuer</th>
|
||||||
|
<th class="text-right">Brutto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($revenue_summary['country_yearly'] as $item)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $item->country_name }}</td>
|
||||||
|
<td class="text-right">{{ number_format($item->total_net, 2, ',', '.') }} €</td>
|
||||||
|
<td class="text-right">{{ number_format($item->total_tax, 2, ',', '.') }} €</td>
|
||||||
|
<td class="text-right"><strong>{{ number_format($item->total_gross, 2, ',', '.') }} €</strong></td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@else
|
||||||
|
<div class="p-3"><p class="text-muted mb-0">Keine Umsätze nach Ländern für {{ session('revenue_filter_year') }} gefunden</p></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gutschriften nach Ländern - Jährlich -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Gutschriften nach Ländern {{ session('revenue_filter_year') }}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if(isset($credit_summary['country_yearly']) && $credit_summary['country_yearly']->count() > 0)
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="thead-light">
|
||||||
|
<tr>
|
||||||
|
<th>Land</th>
|
||||||
|
<th class="text-right">Netto</th>
|
||||||
|
<th class="text-right">Steuer</th>
|
||||||
|
<th class="text-right">Brutto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($credit_summary['country_yearly'] as $item)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $item->country_name }}</td>
|
||||||
|
<td class="text-right">{{ number_format($item->total_net, 2, ',', '.') }} €</td>
|
||||||
|
<td class="text-right">{{ number_format($item->total_tax, 2, ',', '.') }} €</td>
|
||||||
|
<td class="text-right"><strong>{{ number_format($item->total_gross, 2, ',', '.') }} €</strong></td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@else
|
||||||
|
<div class="p-3"><p class="text-muted mb-0">Keine Gutschriften nach Ländern für {{ session('revenue_filter_year') }} gefunden</p></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Umsätze nach Ländern - Monatlich -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Umsätze nach Ländern – monatliche Aufschlüsselung</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if(isset($revenue_summary['country_monthly']) && $revenue_summary['country_monthly']->count() > 0)
|
||||||
|
@php $revenueByMonth = $revenue_summary['country_monthly']->groupBy('month'); @endphp
|
||||||
|
@foreach($revenueByMonth as $month => $countries)
|
||||||
|
<div class="px-3 pt-2 pb-1">
|
||||||
|
<strong class="text-primary">{{ $countries->first()->month_label }}</strong>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm mb-1">
|
||||||
|
<tbody>
|
||||||
|
@foreach($countries as $item)
|
||||||
|
<tr>
|
||||||
|
<td class="pl-4">{{ $item->country_name }}</td>
|
||||||
|
<td class="text-right text-muted"><small>{{ number_format($item->total_net, 2, ',', '.') }} €</small></td>
|
||||||
|
<td class="text-right text-muted"><small>{{ number_format($item->total_tax, 2, ',', '.') }} €</small></td>
|
||||||
|
<td class="text-right"><small><strong>{{ number_format($item->total_gross, 2, ',', '.') }} €</strong></small></td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<div class="p-3"><p class="text-muted mb-0">Keine monatlichen Umsätze nach Ländern gefunden</p></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gutschriften nach Ländern - Monatlich -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Gutschriften nach Ländern – monatliche Aufschlüsselung</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if(isset($credit_summary['country_monthly']) && $credit_summary['country_monthly']->count() > 0)
|
||||||
|
@php $creditByMonth = $credit_summary['country_monthly']->groupBy('month'); @endphp
|
||||||
|
@foreach($creditByMonth as $month => $countries)
|
||||||
|
<div class="px-3 pt-2 pb-1">
|
||||||
|
<strong class="text-primary">{{ $countries->first()->month_label }}</strong>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm mb-1">
|
||||||
|
<tbody>
|
||||||
|
@foreach($countries as $item)
|
||||||
|
<tr>
|
||||||
|
<td class="pl-4">{{ $item->country_name }}</td>
|
||||||
|
<td class="text-right text-muted"><small>{{ number_format($item->total_net, 2, ',', '.') }} €</small></td>
|
||||||
|
<td class="text-right text-muted"><small>{{ number_format($item->total_tax, 2, ',', '.') }} €</small></td>
|
||||||
|
<td class="text-right"><small><strong>{{ number_format($item->total_gross, 2, ',', '.') }} €</strong></small></td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<div class="p-3"><p class="text-muted mb-0">Keine monatlichen Gutschriften nach Ländern gefunden</p></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
@if (isset($activeIncentive) && $activeIncentive)
|
@if (isset($activeIncentive) && $activeIncentive)
|
||||||
|
@php
|
||||||
|
$hasConfirmedDash = $incentiveParticipant && $incentiveParticipant->accepted_terms_at !== null;
|
||||||
|
@endphp
|
||||||
<div class="d-flex col-xl-12 align-items-stretch">
|
<div class="d-flex col-xl-12 align-items-stretch">
|
||||||
<div class="w-100 mb-4 inc-dash-widget">
|
<div class="w-100 mb-4 inc-dash-widget">
|
||||||
|
|
||||||
|
|
@ -60,6 +63,33 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Notice: Teilnahme noch nicht bestätigt --}}
|
||||||
|
@if (!$hasConfirmedDash)
|
||||||
|
<div class="inc-dash-notice mb-3">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="ion ion-md-alert inc-dash-notice-icon mr-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
@if ($incentiveParticipant)
|
||||||
|
<strong
|
||||||
|
class="inc-dash-notice-title">{{ __('incentive.dash_notice_unconfirmed_title') }}</strong>
|
||||||
|
<p class="mb-2 small">{{ __('incentive.dash_notice_unconfirmed_body') }}</p>
|
||||||
|
@else
|
||||||
|
<strong
|
||||||
|
class="inc-dash-notice-title">{{ __('incentive.dash_notice_unregistered_title') }}</strong>
|
||||||
|
<p class="mb-2 small">{{ __('incentive.dash_notice_unregistered_body') }}</p>
|
||||||
|
@endif
|
||||||
|
@if ($activeIncentive->isActive())
|
||||||
|
<button type="button" class="btn inc-dash-btn-notice" data-toggle="modal"
|
||||||
|
data-target="#incParticipateModal">
|
||||||
|
<i class="ion ion-md-checkmark-circle mr-1"></i>
|
||||||
|
{{ __('incentive.dash_notice_btn') }}
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Bilder-Leiste --}}
|
{{-- Bilder-Leiste --}}
|
||||||
@php
|
@php
|
||||||
$dashGallery = [];
|
$dashGallery = [];
|
||||||
|
|
@ -99,6 +129,13 @@
|
||||||
<i class="ion ion-md-list mr-1"></i>
|
<i class="ion ion-md-list mr-1"></i>
|
||||||
{{ __('incentive.dashboard_btn_ranking') }}
|
{{ __('incentive.dashboard_btn_ranking') }}
|
||||||
</a>
|
</a>
|
||||||
|
@if ($incentiveParticipant)
|
||||||
|
<a href="{{ route('user_incentive_details', [$activeIncentive->slug]) }}"
|
||||||
|
class="btn inc-dash-btn-secondary">
|
||||||
|
<i class="ion ion-md-list mr-1"></i>
|
||||||
|
{{ __('incentive.my_calculation') }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,7 +143,117 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Teilnahme-Modal --}}
|
||||||
|
@if (!$hasConfirmedDash && $activeIncentive->isActive())
|
||||||
|
<div class="modal fade" id="incParticipateModal" tabindex="-1" role="dialog"
|
||||||
|
aria-labelledby="incParticipateModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header" style="background: linear-gradient(135deg, #6b7758, #4a5340);">
|
||||||
|
<h5 class="modal-title text-white" id="incParticipateModalLabel">
|
||||||
|
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>
|
||||||
|
{{ __('incentive.dash_modal_title') }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="incDashParticipateForm"
|
||||||
|
action="{{ route('user_incentive_participate', [$activeIncentive->slug]) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-3 small">{{ __('incentive.dash_modal_intro') }}</p>
|
||||||
|
|
||||||
|
@if ($activeIncentive->getLang('description'))
|
||||||
|
<div class="mb-3" style="line-height: 1.6;">
|
||||||
|
{!! $activeIncentive->getLang('description') !!}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($activeIncentive->getLang('terms'))
|
||||||
|
<div class="card mb-4" style="border: 1px solid #e0e0d8;">
|
||||||
|
<div class="card-header py-2 px-3" style="cursor: pointer; background: #f4f5f0;"
|
||||||
|
data-toggle="collapse" data-target="#dashTermsCollapse">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="ion ion-md-document mr-2" style="color: #6b7758;"></i>
|
||||||
|
<strong class="small">{{ __('incentive.terms') }}</strong>
|
||||||
|
<i class="ion ion-md-chevron-down ml-auto text-muted"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="dashTermsCollapse" class="collapse">
|
||||||
|
<div class="card-body small"
|
||||||
|
style="max-height: 280px; overflow-y: auto; line-height: 1.6;">
|
||||||
|
{!! $activeIncentive->getLang('terms') !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="custom-control custom-checkbox">
|
||||||
|
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms"
|
||||||
|
name="accept_terms" value="1" required>
|
||||||
|
<label class="custom-control-label" for="dashAcceptTerms">
|
||||||
|
{{ __('incentive.accept_terms') }}
|
||||||
|
@if ($activeIncentive->getLang('terms'))
|
||||||
|
(<a href="#dashTermsCollapse" data-toggle="collapse"
|
||||||
|
class="text-muted">{{ __('incentive.show_terms') }}</a>)
|
||||||
|
@endif
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">
|
||||||
|
{{ __('incentive.dash_modal_cancel') }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn inc-dash-btn-primary px-4">
|
||||||
|
<i class="ion ion-md-checkmark mr-1"></i>
|
||||||
|
{{ __('incentive.participate_now') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.inc-dash-notice {
|
||||||
|
background: rgba(215, 185, 0, 0.10);
|
||||||
|
border: 1px solid rgba(215, 185, 0, 0.35);
|
||||||
|
border-radius: .6rem;
|
||||||
|
padding: .9rem 1rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-dash-notice-icon {
|
||||||
|
color: #c8a000;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-dash-notice-title {
|
||||||
|
display: block;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: .2rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-dash-btn-notice {
|
||||||
|
background: linear-gradient(135deg, #6b7758, #4a5340);
|
||||||
|
color: #fff !important;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: .4rem 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .82rem;
|
||||||
|
transition: transform .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-dash-btn-notice:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(107, 119, 88, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.inc-dash-widget {
|
.inc-dash-widget {
|
||||||
border-radius: .75rem;
|
border-radius: .75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
114
resources/views/emails/payment-incident-alert.blade.php
Normal file
114
resources/views/emails/payment-incident-alert.blade.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Payment Incident Alert – mivita.care</title>
|
||||||
|
<style type="text/css">
|
||||||
|
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
|
||||||
|
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
|
||||||
|
table { border-collapse: separate !important; }
|
||||||
|
.severity-critical { color: #dc3545; }
|
||||||
|
.severity-high { color: #fd7e14; }
|
||||||
|
.severity-medium { color: #ffc107; }
|
||||||
|
.severity-low { color: #28a745; }
|
||||||
|
.label { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; }
|
||||||
|
.label-danger { background: #dc3545; color: #fff; }
|
||||||
|
.label-warning { background: #ffc107; color: #212529; }
|
||||||
|
.label-success { background: #28a745; color: #fff; }
|
||||||
|
a { color: #919f7a; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="padding:0; margin:0; display:block; background:#f8f8f8;">
|
||||||
|
|
||||||
|
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
|
||||||
|
<br>
|
||||||
|
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 20px;">
|
||||||
|
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" width="200">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table cellpadding="10" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Helvetica, sans-serif; font-size: 18px; font-weight: bold; padding-bottom: 5px;">
|
||||||
|
🚨 {{ $title }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="padding: 20px; border: 1px solid #eee; background-color: #fff8f8; line-height: 1.8em; width: 100%;" cellpadding="4" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td width="160"><strong>Titel</strong></td>
|
||||||
|
<td>{{ $incident->title }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Schwere</strong></td>
|
||||||
|
<td>
|
||||||
|
<span class="label label-{{ $incident->severity === 'critical' ? 'danger' : ($incident->severity === 'high' ? 'warning' : 'success') }}">
|
||||||
|
{{ $incident->severity_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Anbieter</strong></td>
|
||||||
|
<td>{{ $incident->provider_label }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Typ</strong></td>
|
||||||
|
<td>{{ $incident->type_label }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Erkannt am</strong></td>
|
||||||
|
<td>{{ $incident->detected_at->format('d.m.Y H:i') }} Uhr</td>
|
||||||
|
</tr>
|
||||||
|
@if($incident->description)
|
||||||
|
<tr>
|
||||||
|
<td><strong>Beschreibung</strong></td>
|
||||||
|
<td>{{ $incident->description }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@if($incident->affected_orders > 0)
|
||||||
|
<tr>
|
||||||
|
<td><strong>Betroffene Bestellungen</strong></td>
|
||||||
|
<td>{{ $incident->affected_orders }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@if($incident->affected_revenue > 0)
|
||||||
|
<tr>
|
||||||
|
<td><strong>Betroffener Umsatz</strong></td>
|
||||||
|
<td>{{ number_format($incident->affected_revenue, 2, ',', '.') }} €</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align: center; padding: 20px 0;">
|
||||||
|
<a href="{{ $dashboardUrl }}"
|
||||||
|
style="display: inline-block; padding: 10px 24px; background-color: #dc3545; color: #ffffff; font-family: Helvetica, sans-serif; font-size: 14px; font-weight: bold; border-radius: 4px; text-decoration: none;">
|
||||||
|
Incident im Dashboard öffnen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#7B7B7E; font-size:13px; text-align: center; padding: 20px 0;">
|
||||||
|
Diese Nachricht wurde automatisch von mivita.care generiert.<br>
|
||||||
|
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -617,6 +617,25 @@
|
||||||
<div>{{ __('navigation.tools') }}</div>
|
<div>{{ __('navigation.tools') }}</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidenav-item{{ Request::is('admin/payment-dashboard*') ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('admin.payment-dashboard.index') }}" class="sidenav-link">
|
||||||
|
<i class="sidenav-icon ion ion-md-alert"></i>
|
||||||
|
<div>{{ __('navigation.payment_monitor') }}</div>
|
||||||
|
@php $openIncidentCount = Cache::remember('open_incident_count', 60, fn() => \App\Models\PaymentIncident::whereIn('status', ['open','in_progress','waiting_provider'])->count()); @endphp
|
||||||
|
@if($openIncidentCount > 0)
|
||||||
|
<div class="pl-1 ml-auto">
|
||||||
|
<div class="badge badge-danger">{{ $openIncidentCount }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{-- GF-Ansicht: vorerst auskommentiert --}}
|
||||||
|
{{-- <li class="sidenav-item{{ Request::is('admin/payment-dashboard/management') ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('admin.payment-dashboard.management') }}" class="sidenav-link">
|
||||||
|
<i class="sidenav-icon ion ion-md-stats"></i>
|
||||||
|
<div>{{ __('navigation.payment_monitor_management') }}</div>
|
||||||
|
</a>
|
||||||
|
</li> --}}
|
||||||
@endif
|
@endif
|
||||||
@if (Auth::user()->isSySAdmin())
|
@if (Auth::user()->isSySAdmin())
|
||||||
<li class="sidenav-divider mb-1"></li>
|
<li class="sidenav-divider mb-1"></li>
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,49 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vip-ranking-notice {
|
||||||
|
background: rgba(107, 119, 88, 0.08);
|
||||||
|
border-bottom: 1px solid rgba(107, 119, 88, 0.15);
|
||||||
|
padding: .6rem 1.5rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: #6b7758;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-terms-accepted {
|
||||||
|
color: #5a8a5a;
|
||||||
|
font-size: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-terms-pending {
|
||||||
|
color: #c0392b;
|
||||||
|
font-size: 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-ranking-card .pagination {
|
||||||
|
margin: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-ranking-card .page-item .page-link {
|
||||||
|
color: #6b7758;
|
||||||
|
border-color: #e0e0d8;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-ranking-card .page-item.active .page-link {
|
||||||
|
background-color: #6b7758;
|
||||||
|
border-color: #6b7758;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inc-ranking-card .page-item.disabled .page-link {
|
||||||
|
color: #bbb;
|
||||||
|
border-color: #e0e0d8;
|
||||||
|
}
|
||||||
|
|
||||||
.pending-banner {
|
.pending-banner {
|
||||||
background: rgba(215, 215, 0, 0.12);
|
background: rgba(215, 215, 0, 0.12);
|
||||||
border: 1px solid rgba(215, 215, 0, 0.3);
|
border: 1px solid rgba(215, 215, 0, 0.3);
|
||||||
|
|
@ -857,7 +900,7 @@
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<i class="ion ion-md-list mr-2" style="font-size: 1.2rem; color: #6b7758;"></i>
|
<i class="ion ion-md-list mr-2" style="font-size: 1.2rem; color: #6b7758;"></i>
|
||||||
<span class="ranking-title">{{ __('incentive.section_ranking') }}</span>
|
<span class="ranking-title">{{ __('incentive.section_ranking') }}</span>
|
||||||
<span class="badge-top ml-2">Top {{ $rankingDisplayLimit }}</span>
|
<span class="badge-top ml-2">{{ __('incentive.ranking_all_active') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="hint-text">{{ __('incentive.ranking_winners_hint', ['n' => $incentive->max_winners]) }}</span>
|
class="hint-text">{{ __('incentive.ranking_winners_hint', ['n' => $incentive->max_winners]) }}</span>
|
||||||
|
|
@ -868,6 +911,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
@if ($isVipView)
|
||||||
|
<div class="vip-ranking-notice">
|
||||||
|
<i class="ion ion-md-eye mr-1"></i>
|
||||||
|
{{ __('incentive.vip_view_notice') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@if ($ranking->isEmpty())
|
@if ($ranking->isEmpty())
|
||||||
<div class="p-4 text-center text-muted">
|
<div class="p-4 text-center text-muted">
|
||||||
<i class="ion ion-md-people mb-2 d-block" style="font-size: 2.5rem; opacity: .4;"></i>
|
<i class="ion ion-md-people mb-2 d-block" style="font-size: 2.5rem; opacity: .4;"></i>
|
||||||
|
|
@ -904,7 +953,16 @@
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@if ($p->accepted_terms_at || $isVipView)
|
||||||
|
@if ($isVipView)
|
||||||
@if ($p->accepted_terms_at)
|
@if ($p->accepted_terms_at)
|
||||||
|
<i class="ion ion-md-checkmark-circle vip-terms-accepted ml-1"
|
||||||
|
title="{{ __('incentive.vip_terms_accepted') }}"></i>
|
||||||
|
@else
|
||||||
|
<i class="ion ion-md-close-circle vip-terms-pending ml-1"
|
||||||
|
title="{{ __('incentive.vip_terms_pending') }}"></i>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
@if ($p->user && $p->user->account)
|
@if ($p->user && $p->user->account)
|
||||||
{{ $p->user->account->first_name }} {{ $p->user->account->last_name }}
|
{{ $p->user->account->first_name }} {{ $p->user->account->last_name }}
|
||||||
@else
|
@else
|
||||||
|
|
@ -947,6 +1005,11 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($ranking->hasPages())
|
||||||
|
<div class="p-3">
|
||||||
|
{{ $ranking->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,72 +2,147 @@
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$isCancel = ($error_type ?? 'error') === 'cancel';
|
||||||
|
$errorcode = $errorcode ?? null;
|
||||||
|
$errorDescription = $error_description ?? null;
|
||||||
|
|
||||||
|
// Fehlercode → konkreten Hinweistext ermitteln
|
||||||
|
$errorReason = null;
|
||||||
|
if (!$isCancel && $errorcode) {
|
||||||
|
$code = (int) $errorcode;
|
||||||
|
if (in_array($code, [33])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.card_expired');
|
||||||
|
} elseif (in_array($code, [4, 34])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.card_blocked');
|
||||||
|
} elseif (in_array($code, [12, 14, 105])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.card_invalid');
|
||||||
|
} elseif (in_array($code, [5, 902, 4219])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.card_declined');
|
||||||
|
} elseif (in_array($code, [130])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.insufficient_funds');
|
||||||
|
} elseif (in_array($code, [120])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.cvv_invalid');
|
||||||
|
} elseif (in_array($code, [900])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.3ds_failed');
|
||||||
|
} elseif (in_array($code, [970, 135])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.timeout');
|
||||||
|
} elseif (in_array($code, [4218])) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.fraud');
|
||||||
|
} else {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.general');
|
||||||
|
}
|
||||||
|
} elseif (!$isCancel) {
|
||||||
|
$errorReason = __('payment.payment_error_reasons.general');
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
<section class="page-header page-header-xlg parallax parallax-3"
|
<section class="page-header page-header-xlg parallax parallax-3"
|
||||||
style="background-image:url('/assets/images/vision-min.jpg')">
|
style="background-image:url('/assets/images/vision-min.jpg')">
|
||||||
<div class="overlay dark-1"><!-- dark overlay [1 to 9 opacity] --></div>
|
<div class="overlay dark-1"></div>
|
||||||
|
<div class="container"></div>
|
||||||
<div class="container">
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<!-- /PAGE HEADER -->
|
|
||||||
|
|
||||||
<style>
|
<section class="py-5">
|
||||||
div.shop-item {
|
|
||||||
margin-bottom:30px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
div.shop-item > .thumbnail, .thumbnail {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
div.shop-item-summary {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
div.shop-item-summary h2 a {
|
|
||||||
color: #9aa983;
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
}
|
|
||||||
div.shop-item-buttons {
|
|
||||||
padding: 0 8px 10px 8px;
|
|
||||||
}
|
|
||||||
div.shop-item-buttons .btn-xs{
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!-- -->
|
|
||||||
|
|
||||||
<!-- -->
|
|
||||||
<section>
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-2 col-md-12"></div>
|
||||||
|
<div class="col-md-12 col-lg-8">
|
||||||
|
|
||||||
<!-- CHECKOUT ERROR MESSAGE -->
|
@if ($isCancel)
|
||||||
|
{{-- ── ABGEBROCHEN ──────────────────────────────── --}}
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-body">
|
<div class="panel-body text-center py-4">
|
||||||
<div class="alert alert-danger">
|
<div style="font-size:3rem; color:#f0ad4e;" class="mb-3">
|
||||||
<h3><i class="fa fa-exclamation-triangle"></i> {{ $error_title ?? __('payment.payment_error') }}</h3>
|
<i class="fa fa-ban"></i>
|
||||||
<p>{{ $error_message ?? __('payment.payment_error_description') }}</p>
|
</div>
|
||||||
|
<h3 class="mb-2">{{ $error_title }}</h3>
|
||||||
|
<p class="text-muted mb-1">{{ $error_message }}</p>
|
||||||
|
<p class="text-muted small mb-4">{{ __('payment.nothing_was_charged') }}</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info text-left py-2 mb-4" style="font-size:0.9rem;">
|
||||||
|
<i class="fa fa-info-circle mr-1"></i>
|
||||||
|
{{ __('payment.payment_canceled_hint') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
@if (isset($checkout_url))
|
||||||
|
<a href="{{ $checkout_url }}" class="btn btn-primary btn-lg btn-block mb-2">
|
||||||
<p>{{ __('payment.contact_support_if_needed') }}</p>
|
<i class="fa fa-refresh mr-1"></i>
|
||||||
|
{{ __('payment.try_again') }}
|
||||||
<p>
|
</a>
|
||||||
<strong>{{ __('payment.your_mivita_team') }}</strong>
|
@endif
|
||||||
</p>
|
@if ($user_shop)
|
||||||
|
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}"
|
||||||
@if($user_shop)
|
class="btn btn-default btn-block">
|
||||||
<div class="mt-4">
|
<i class="fa fa-arrow-left mr-1"></i> {{ __('payment.back_to_shop') }}
|
||||||
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}" class="btn btn-primary">
|
|
||||||
<i class="fa fa-arrow-left"></i> {{ __('payment.back_to_shop') }}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /CHECKOUT ERROR MESSAGE -->
|
@else
|
||||||
|
{{-- ── FEHLER ───────────────────────────────────── --}}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-body py-4">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<div style="font-size:3rem; color:#d9534f;">
|
||||||
|
<i class="fa fa-exclamation-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-2 mb-1">{{ $error_title }}</h3>
|
||||||
|
<p class="text-muted mb-0">{{ $error_message }}</p>
|
||||||
|
<small class="text-muted">{{ __('payment.nothing_was_charged') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{{-- Konkreter Hinweis basierend auf Fehlercode --}}
|
||||||
|
<div class="alert alert-warning mb-3" style="font-size:0.9rem;">
|
||||||
|
<strong><i
|
||||||
|
class="fa fa-lightbulb-o mr-1"></i>{{ __('payment.payment_error_what_to_do') }}</strong><br>
|
||||||
|
{{ $errorReason }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Fehlerbeschreibung + Code (für Transparenz) --}}
|
||||||
|
@if ($errorcode || $errorDescription)
|
||||||
|
<div class="panel panel-default mb-3" style="font-size:0.82rem;">
|
||||||
|
<div class="panel-heading py-1 px-3" style="font-size:0.82rem;">
|
||||||
|
<strong>{{ __('payment.payment_error_code') }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body py-2 px-3">
|
||||||
|
@if ($errorcode)
|
||||||
|
<span class="label label-danger mr-2">{{ $errorcode }}</span>
|
||||||
|
@endif
|
||||||
|
@if ($errorDescription)
|
||||||
|
<span class="text-muted">{{ $errorDescription }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Aktionsbuttons --}}
|
||||||
|
@if (isset($checkout_url))
|
||||||
|
<a href="{{ $checkout_url }}" class="btn btn-primary btn-block btn-lg mb-2">
|
||||||
|
<i class="fa fa-refresh mr-1"></i>
|
||||||
|
{{ __('payment.payment_error_retry') }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@if ($user_shop)
|
||||||
|
<a href="{{ config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') }}"
|
||||||
|
class="btn btn-default btn-block mb-3">
|
||||||
|
<i class="fa fa-arrow-left mr-1"></i> {{ __('payment.back_to_shop') }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="text-muted text-center small mb-0">
|
||||||
|
<i class="fa fa-envelope-o mr-1"></i>
|
||||||
|
{{ __('payment.contact_support_if_needed') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<!-- / -->
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,16 @@
|
||||||
@if (\Session::has('errormessage'))
|
@if (\Session::has('errormessage'))
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger" role="alert">
|
||||||
{{ \Session::get('customermessage') }}
|
<h5 class="alert-heading mb-1">
|
||||||
|
<i class="fa fa-exclamation-circle mr-1"></i>
|
||||||
|
{{ __('payment.payment_error') }}
|
||||||
|
</h5>
|
||||||
|
@if(\Session::get('customermessage'))
|
||||||
|
<p class="mb-1">{{ \Session::get('customermessage') }}</p>
|
||||||
|
@endif
|
||||||
|
<hr class="my-2">
|
||||||
|
<p class="mb-0 small">{{ __('payment.payment_error_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,21 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
||||||
Route::get('/admin/revenue', 'RevenueReportController@index')->name('admin_revenue');
|
Route::get('/admin/revenue', 'RevenueReportController@index')->name('admin_revenue');
|
||||||
Route::post('/admin/revenue/export', 'RevenueReportController@export')->name('admin_revenue_export');
|
Route::post('/admin/revenue/export', 'RevenueReportController@export')->name('admin_revenue_export');
|
||||||
|
|
||||||
|
// Payment Dashboard / Monitor
|
||||||
|
Route::prefix('/admin/payment-dashboard')->name('admin.payment-dashboard.')->group(function () {
|
||||||
|
Route::get('/', 'Admin\PaymentDashboardController@index')->name('index');
|
||||||
|
Route::get('/management', 'Admin\PaymentDashboardController@management')->name('management');
|
||||||
|
Route::get('/transactions', 'Admin\PaymentDashboardController@transactions')->name('transactions');
|
||||||
|
Route::get('/payments', 'Admin\PaymentDashboardController@payments')->name('payments');
|
||||||
|
Route::get('/abandoned', 'Admin\PaymentDashboardController@abandoned')->name('abandoned');
|
||||||
|
Route::get('/funnel', 'Admin\PaymentDashboardController@funnel')->name('funnel');
|
||||||
|
Route::get('/logs', 'Admin\PaymentDashboardController@logs')->name('logs');
|
||||||
|
Route::post('/', 'Admin\PaymentDashboardController@store')->name('store');
|
||||||
|
Route::get('/{incident}', 'Admin\PaymentDashboardController@show')->name('show');
|
||||||
|
Route::post('/{incident}/activity', 'Admin\PaymentDashboardController@addActivity')->name('activity.store');
|
||||||
|
Route::patch('/{incident}/status', 'Admin\PaymentDashboardController@updateStatus')->name('status.update');
|
||||||
|
});
|
||||||
|
|
||||||
// business
|
// business
|
||||||
Route::get('/admin/business/show', 'BusinessController@show')->name('admin_business_show');
|
Route::get('/admin/business/show', 'BusinessController@show')->name('admin_business_show');
|
||||||
Route::get('/admin/business/structure', 'BusinessController@structure')->name('admin_business_structure');
|
Route::get('/admin/business/structure', 'BusinessController@structure')->name('admin_business_structure');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\User\IncentiveController;
|
|
||||||
use App\Models\Incentive;
|
use App\Models\Incentive;
|
||||||
use App\Models\IncentiveParticipant;
|
use App\Models\IncentiveParticipant;
|
||||||
use App\User;
|
use App\User;
|
||||||
|
|
@ -113,7 +112,7 @@ it('sortiert bei Punktgleichstand Teilnehmer mit Klarnamen (bestaetigte Teilnahm
|
||||||
expect($orderedUserIds)->toBe([$confirmed->user_id, $anonymous->user_id]);
|
expect($orderedUserIds)->toBe([$confirmed->user_id, $anonymous->user_id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('begrenzt die User-Rangliste auf 30 Plaetze (Gewinner-Zone bleibt max_winners)', function () {
|
it('zeigt alle Teilnehmer mit Aktivitaet in der User-Rangliste (kein Limit)', function () {
|
||||||
$incentive = Incentive::factory()->create(['max_winners' => 20]);
|
$incentive = Incentive::factory()->create(['max_winners' => 20]);
|
||||||
|
|
||||||
$makeUser = fn () => User::forceCreate([
|
$makeUser = fn () => User::forceCreate([
|
||||||
|
|
@ -130,13 +129,21 @@ it('begrenzt die User-Rangliste auf 30 Plaetze (Gewinner-Zone bleibt max_winners
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teilnehmer ohne Punkte soll nicht erscheinen
|
||||||
|
IncentiveParticipant::factory()->create([
|
||||||
|
'incentive_id' => $incentive->id,
|
||||||
|
'user_id' => $makeUser()->id,
|
||||||
|
'total_points' => 0,
|
||||||
|
'qualified_partners' => 0,
|
||||||
|
'qualified_abos' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
$ranking = IncentiveParticipant::where('incentive_id', $incentive->id)
|
$ranking = IncentiveParticipant::where('incentive_id', $incentive->id)
|
||||||
->withRankingActivity()
|
->withRankingActivity()
|
||||||
->orderByIncentiveLeaderboard()
|
->orderByIncentiveLeaderboard()
|
||||||
->limit(IncentiveController::USER_RANKING_DISPLAY_LIMIT)
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
expect($ranking)->toHaveCount(30);
|
expect($ranking)->toHaveCount(35);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blendet in der User-Ranglogik Teilnehmer ohne Partner, Abo und Punkte aus', function () {
|
it('blendet in der User-Ranglogik Teilnehmer ohne Partner, Abo und Punkte aus', function () {
|
||||||
|
|
|
||||||
112
tests/Feature/PaymentDashboard/CheckPaymentUptimeCommandTest.php
Normal file
112
tests/Feature/PaymentDashboard/CheckPaymentUptimeCommandTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\PaymentIncidentAlert;
|
||||||
|
use App\Models\IncidentActivity;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use App\Models\ProviderUptimeLog;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
// ─── Uptime-Command: Erfolgsfall ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('schreibt einen positiven ProviderUptimeLog wenn PAYONE erreichbar ist', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api.pay1.de/*' => Http::response('', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('payment:check-uptime')->assertSuccessful();
|
||||||
|
|
||||||
|
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
|
||||||
|
expect($log)->not->toBeNull();
|
||||||
|
expect($log->is_up)->toBeTrue();
|
||||||
|
expect($log->error_message)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt einen negativen ProviderUptimeLog und legt Incident an wenn PAYONE nicht erreichbar ist', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api.pay1.de/*' => Http::response('Server Error', 503),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('payment:check-uptime')->assertSuccessful();
|
||||||
|
|
||||||
|
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
|
||||||
|
expect($log)->not->toBeNull();
|
||||||
|
expect($log->is_up)->toBeFalse();
|
||||||
|
|
||||||
|
$incident = PaymentIncident::where('provider', 'payone')->where('type', 'outage')->first();
|
||||||
|
expect($incident)->not->toBeNull();
|
||||||
|
expect($incident->severity)->toBe('critical');
|
||||||
|
expect($incident->status)->toBe('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erstellt keine doppelten Outage-Incidents bei mehrfachem Ausfall in derselben Stunde', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api.pay1.de/*' => Http::response('', 503),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('payment:check-uptime');
|
||||||
|
$this->artisan('payment:check-uptime');
|
||||||
|
|
||||||
|
expect(PaymentIncident::where('provider', 'payone')->where('type', 'outage')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('löst offene Outage-Incidents automatisch auf wenn PAYONE wieder erreichbar ist', function () {
|
||||||
|
PaymentIncident::create([
|
||||||
|
'title' => 'Automatischer Ausfall',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'outage',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'status' => 'open',
|
||||||
|
'detected_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.pay1.de/*' => Http::response('', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('payment:check-uptime')->assertSuccessful();
|
||||||
|
|
||||||
|
$incident = PaymentIncident::where('provider', 'payone')->where('type', 'outage')->first();
|
||||||
|
expect($incident->fresh()->status)->toBe('resolved');
|
||||||
|
expect($incident->fresh()->resolved_at)->not->toBeNull();
|
||||||
|
|
||||||
|
$activity = IncidentActivity::where('incident_id', $incident->id)
|
||||||
|
->where('type', 'status_change')
|
||||||
|
->first();
|
||||||
|
expect($activity)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verarbeitet Verbindungsfehler als Ausfall', function () {
|
||||||
|
Http::fake([
|
||||||
|
'api.pay1.de/*' => fn () => throw new \Exception('Connection refused'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('payment:check-uptime')->assertSuccessful();
|
||||||
|
|
||||||
|
$log = ProviderUptimeLog::where('provider', 'payone')->latest()->first();
|
||||||
|
expect($log->is_up)->toBeFalse();
|
||||||
|
expect($log->error_message)->toContain('Connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mailable ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('sendet PaymentIncidentAlert-Mail wenn critical Incident angelegt wird', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Kritischer Test-Incident',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'outage',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Mail::to(config('app.exception_mail'))->queue(new PaymentIncidentAlert($incident));
|
||||||
|
|
||||||
|
Mail::assertQueued(PaymentIncidentAlert::class, function (PaymentIncidentAlert $mail) use ($incident) {
|
||||||
|
return $mail->incident->id === $incident->id;
|
||||||
|
});
|
||||||
|
});
|
||||||
101
tests/Feature/PaymentDashboard/CheckoutFunnelTrackingTest.php
Normal file
101
tests/Feature/PaymentDashboard/CheckoutFunnelTrackingTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\CheckoutFunnelEvent;
|
||||||
|
use App\Services\CheckoutFunnelTracker;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('speichert ein checkout_visited Ereignis', function () {
|
||||||
|
CheckoutFunnelTracker::visitedCheckout(consultantUserId: 42, metadata: ['is_from' => 'shopping']);
|
||||||
|
|
||||||
|
expect(CheckoutFunnelEvent::count())->toBe(1);
|
||||||
|
|
||||||
|
$event = CheckoutFunnelEvent::first();
|
||||||
|
expect($event->event)->toBe('checkout_visited');
|
||||||
|
expect($event->consultant_user_id)->toBe(42);
|
||||||
|
expect($event->metadata)->toMatchArray(['is_from' => 'shopping']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert ein form_submitted Ereignis', function () {
|
||||||
|
CheckoutFunnelTracker::submittedForm(
|
||||||
|
shoppingUserId: 1,
|
||||||
|
shoppingOrderId: 10,
|
||||||
|
consultantUserId: 5,
|
||||||
|
paymentMethod: 'elv',
|
||||||
|
amountCents: 4990,
|
||||||
|
);
|
||||||
|
|
||||||
|
$event = CheckoutFunnelEvent::first();
|
||||||
|
expect($event->event)->toBe('form_submitted');
|
||||||
|
expect($event->shopping_user_id)->toBe(1);
|
||||||
|
expect($event->shopping_order_id)->toBe(10);
|
||||||
|
expect($event->payment_method)->toBe('elv');
|
||||||
|
expect($event->amount_cents)->toBe(4990);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert ein payment_initiated Ereignis', function () {
|
||||||
|
CheckoutFunnelTracker::initiatedPayment(
|
||||||
|
shoppingUserId: 1,
|
||||||
|
shoppingOrderId: 10,
|
||||||
|
shoppingPaymentId: 20,
|
||||||
|
consultantUserId: 5,
|
||||||
|
paymentMethod: 'cc',
|
||||||
|
amountCents: 9900,
|
||||||
|
);
|
||||||
|
|
||||||
|
$event = CheckoutFunnelEvent::first();
|
||||||
|
expect($event->event)->toBe('payment_initiated');
|
||||||
|
expect($event->shopping_payment_id)->toBe(20);
|
||||||
|
expect($event->amount_cents)->toBe(9900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert ein payment_returned Ereignis mit return_status', function () {
|
||||||
|
CheckoutFunnelTracker::returnedFromPayment(
|
||||||
|
shoppingPaymentId: 20,
|
||||||
|
returnStatus: 'cancel',
|
||||||
|
);
|
||||||
|
|
||||||
|
$event = CheckoutFunnelEvent::first();
|
||||||
|
expect($event->event)->toBe('payment_returned');
|
||||||
|
expect($event->return_status)->toBe('cancel');
|
||||||
|
expect($event->shopping_payment_id)->toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert ein payment_confirmed Ereignis', function () {
|
||||||
|
CheckoutFunnelTracker::confirmedPayment(
|
||||||
|
shoppingPaymentId: 20,
|
||||||
|
txaction: 'paid',
|
||||||
|
);
|
||||||
|
|
||||||
|
$event = CheckoutFunnelEvent::first();
|
||||||
|
expect($event->event)->toBe('payment_confirmed');
|
||||||
|
expect($event->metadata)->toMatchArray(['txaction' => 'paid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schluckt Exceptions und loggt nur eine Warnung', function () {
|
||||||
|
// Ungültiges event-Enum — soll keinen Fehler werfen
|
||||||
|
expect(fn () => CheckoutFunnelTracker::visitedCheckout())->not->toThrow(\Throwable::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Funnel-View gibt korrekten View zurück', function () {
|
||||||
|
$admin = \App\User::forceCreate([
|
||||||
|
'email' => 'admin-funnel-'.uniqid().'@test.com',
|
||||||
|
'password' => \Illuminate\Support\Facades\Hash::make('secret'),
|
||||||
|
'admin' => 2,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
CheckoutFunnelTracker::visitedCheckout();
|
||||||
|
CheckoutFunnelTracker::submittedForm(1, 1, null, 'elv', 3990);
|
||||||
|
|
||||||
|
$controller = new \App\Http\Controllers\Admin\PaymentDashboardController;
|
||||||
|
$response = $controller->funnel();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.funnel');
|
||||||
|
$data = $response->getData();
|
||||||
|
expect($data)->toHaveKey('funnelSteps');
|
||||||
|
expect($data['funnelSteps'][0]['count'])->toBe(1);
|
||||||
|
expect($data['funnelSteps'][1]['count'])->toBe(1);
|
||||||
|
});
|
||||||
183
tests/Feature/PaymentDashboard/PaymentDashboardAccessTest.php
Normal file
183
tests/Feature/PaymentDashboard/PaymentDashboardAccessTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\PaymentDashboardController;
|
||||||
|
use App\Http\Middleware\Admin;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function makeAdminUser(): User
|
||||||
|
{
|
||||||
|
return User::forceCreate([
|
||||||
|
'email' => 'admin-'.uniqid().'@test.com',
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'admin' => 2,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRegularUser(): User
|
||||||
|
{
|
||||||
|
return User::forceCreate([
|
||||||
|
'email' => 'user-'.uniqid().'@test.com',
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'admin' => 0,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVipUser(): User
|
||||||
|
{
|
||||||
|
return User::forceCreate([
|
||||||
|
'email' => 'vip-'.uniqid().'@test.com',
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'admin' => 1,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin Middleware Tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('Admin-Middleware lässt Admins (admin >= 2) durch', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
Auth::setUser($admin);
|
||||||
|
|
||||||
|
$request = Request::create('/admin/payment-dashboard');
|
||||||
|
|
||||||
|
$middleware = new Admin;
|
||||||
|
$passed = false;
|
||||||
|
$middleware->handle($request, function () use (&$passed) {
|
||||||
|
$passed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($passed)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admin-Middleware blockiert normale Benutzer (admin = 0)', function () {
|
||||||
|
$user = makeRegularUser();
|
||||||
|
$request = Request::create('/admin/payment-dashboard');
|
||||||
|
$request->setUserResolver(fn () => $user);
|
||||||
|
|
||||||
|
$middleware = new Admin;
|
||||||
|
$response = $middleware->handle($request, fn () => null);
|
||||||
|
|
||||||
|
expect($response)->not->toBeNull();
|
||||||
|
expect($response->getStatusCode())->toBe(302);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admin-Middleware blockiert VIP-Benutzer (admin = 1)', function () {
|
||||||
|
$vip = makeVipUser();
|
||||||
|
$request = Request::create('/admin/payment-dashboard');
|
||||||
|
$request->setUserResolver(fn () => $vip);
|
||||||
|
|
||||||
|
$middleware = new Admin;
|
||||||
|
$response = $middleware->handle($request, fn () => null);
|
||||||
|
|
||||||
|
expect($response)->not->toBeNull();
|
||||||
|
expect($response->getStatusCode())->toBe(302);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Controller Auth Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('Entwickler-Ansicht gibt View zurück für Admins', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->index();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GF-Ansicht gibt View zurück für Super-Admins (admin >= 3)', function () {
|
||||||
|
$superAdmin = User::forceCreate([
|
||||||
|
'email' => 'superadmin-'.uniqid().'@test.com',
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'admin' => 3,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
$this->actingAs($superAdmin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->management();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.management');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GF-Ansicht liefert 403 für normale Admins (admin = 2)', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
|
||||||
|
expect(fn () => $controller->management())->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Incident-Detail gibt korrekten View zurück', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Test Incident Detail',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'payment_failure',
|
||||||
|
'severity' => 'high',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->show($incident);
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.show');
|
||||||
|
expect($response->getData()['incident']->id)->toBe($incident->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Log-Ansicht gibt View zurück', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->logs();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Transaktions-Ansicht gibt View zurück', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->transactions();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.transactions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Abbruch-Analyse gibt View zurück', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->abandoned();
|
||||||
|
|
||||||
|
expect($response->getName())->toBe('admin.payment-dashboard.abandoned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Abbruch-Analyse enthält die 3 erwarteten Datensätze', function () {
|
||||||
|
$admin = makeAdminUser();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$response = $controller->abandoned();
|
||||||
|
|
||||||
|
$data = $response->getData();
|
||||||
|
expect($data)->toHaveKey('ordersWithoutPayment');
|
||||||
|
expect($data)->toHaveKey('cancelledPayments');
|
||||||
|
expect($data)->toHaveKey('pendingPayments');
|
||||||
|
expect($data)->toHaveKey('abandonedStats');
|
||||||
|
expect($data['abandonedStats'])->toHaveKeys(['no_payment', 'cancelled', 'no_callback']);
|
||||||
|
});
|
||||||
298
tests/Feature/PaymentDashboard/PaymentIncidentCrudTest.php
Normal file
298
tests/Feature/PaymentDashboard/PaymentIncidentCrudTest.php
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\PaymentDashboardController;
|
||||||
|
use App\Http\Requests\PaymentIncident\AddIncidentActivityRequest;
|
||||||
|
use App\Http\Requests\PaymentIncident\StorePaymentIncidentRequest;
|
||||||
|
use App\Http\Requests\PaymentIncident\UpdateIncidentStatusRequest;
|
||||||
|
use App\Models\IncidentActivity;
|
||||||
|
use App\Models\PaymentIncident;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function makeAdmin(): User
|
||||||
|
{
|
||||||
|
return User::forceCreate([
|
||||||
|
'email' => 'admin-crud-'.uniqid().'@test.com',
|
||||||
|
'password' => Hash::make('secret'),
|
||||||
|
'admin' => 2,
|
||||||
|
'lang' => 'de',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model / Scopes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('scopeOpen gibt nur offene, laufende und wartende Incidents zurück', function () {
|
||||||
|
PaymentIncident::create(['title' => 'Offen', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'open', 'detected_at' => now()]);
|
||||||
|
PaymentIncident::create(['title' => 'In Bearbeitung', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'in_progress', 'detected_at' => now()]);
|
||||||
|
PaymentIncident::create(['title' => 'Wartet', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'waiting_provider', 'detected_at' => now()]);
|
||||||
|
PaymentIncident::create(['title' => 'Gelöst', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'status' => 'resolved', 'detected_at' => now()]);
|
||||||
|
|
||||||
|
$open = PaymentIncident::open()->get();
|
||||||
|
|
||||||
|
expect($open)->toHaveCount(3);
|
||||||
|
expect($open->pluck('title')->toArray())->not->toContain('Gelöst');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopePayone filtert nach PAYONE', function () {
|
||||||
|
PaymentIncident::create(['title' => 'PAYONE Incident', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
|
||||||
|
PaymentIncident::create(['title' => 'Stripe Incident', 'provider' => 'stripe', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
|
||||||
|
|
||||||
|
expect(PaymentIncident::payone()->count())->toBe(1);
|
||||||
|
expect(PaymentIncident::payone()->first()->title)->toBe('PAYONE Incident');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopeLastDays filtert nach Zeitraum', function () {
|
||||||
|
PaymentIncident::create(['title' => 'Alt', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()->subDays(10)]);
|
||||||
|
PaymentIncident::create(['title' => 'Neu', 'provider' => 'payone', 'type' => 'other', 'severity' => 'low', 'detected_at' => now()]);
|
||||||
|
|
||||||
|
expect(PaymentIncident::lastDays(7)->count())->toBe(1);
|
||||||
|
expect(PaymentIncident::lastDays(7)->first()->title)->toBe('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Accessor severity_color gibt Bootstrap-Klasse zurück', function () {
|
||||||
|
$incident = new PaymentIncident(['severity' => 'critical']);
|
||||||
|
expect($incident->severity_color)->toBe('danger');
|
||||||
|
|
||||||
|
$incident = new PaymentIncident(['severity' => 'high']);
|
||||||
|
expect($incident->severity_color)->toBe('warning');
|
||||||
|
|
||||||
|
$incident = new PaymentIncident(['severity' => 'low']);
|
||||||
|
expect($incident->severity_color)->toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Accessor duration berechnet Dauer korrekt', function () {
|
||||||
|
$incident = new PaymentIncident([
|
||||||
|
'detected_at' => now()->subMinutes(90),
|
||||||
|
'resolved_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($incident->duration)->toContain('h');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Controller: Incident anlegen ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('legt einen neuen Incident an und erstellt automatisch eine erste Aktivität', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$request = StorePaymentIncidentRequest::create('/admin/payment-dashboard', 'POST', [
|
||||||
|
'title' => 'PAYONE IPN ausgefallen',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'ipn_error',
|
||||||
|
'severity' => 'high',
|
||||||
|
'detected_at' => now()->format('Y-m-d H:i:s'),
|
||||||
|
'affected_orders' => 5,
|
||||||
|
'affected_revenue' => '450.00',
|
||||||
|
]);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new PaymentDashboardController)->store($request);
|
||||||
|
} catch (\Symfony\Component\Routing\Exception\RouteNotFoundException $e) {
|
||||||
|
// Domain-spezifische Routen sind im Test-Kontext nicht registriert – erwartet.
|
||||||
|
}
|
||||||
|
|
||||||
|
$incident = PaymentIncident::where('title', 'PAYONE IPN ausgefallen')->first();
|
||||||
|
expect($incident)->not->toBeNull();
|
||||||
|
expect($incident->provider)->toBe('payone');
|
||||||
|
expect($incident->status)->toBe('open');
|
||||||
|
expect($incident->affected_orders)->toBe(5);
|
||||||
|
|
||||||
|
expect(IncidentActivity::where('incident_id', $incident->id)->count())->toBe(1);
|
||||||
|
expect(IncidentActivity::where('incident_id', $incident->id)->first()->type)->toBe('note');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── FormRequest Validierung ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('validiert Pflichtfelder beim Anlegen eines Incidents', function () {
|
||||||
|
$rules = (new StorePaymentIncidentRequest)->rules();
|
||||||
|
$validator = Validator::make([], $rules);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
expect($validator->errors()->has('title'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('provider'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('type'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('severity'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('detected_at'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validiert ungültige Enum-Werte in StorePaymentIncidentRequest', function () {
|
||||||
|
$rules = (new StorePaymentIncidentRequest)->rules();
|
||||||
|
$validator = Validator::make([
|
||||||
|
'title' => 'Test',
|
||||||
|
'provider' => 'bitcoin',
|
||||||
|
'type' => 'unknown',
|
||||||
|
'severity' => 'extreme',
|
||||||
|
'detected_at' => now()->toDateTimeString(),
|
||||||
|
], $rules);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
expect($validator->errors()->has('provider'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('type'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('severity'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validiert Pflichtfelder in AddIncidentActivityRequest', function () {
|
||||||
|
$rules = (new AddIncidentActivityRequest)->rules();
|
||||||
|
$validator = Validator::make([], $rules);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
expect($validator->errors()->has('type'))->toBeTrue();
|
||||||
|
expect($validator->errors()->has('title'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Controller: Status ändern ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('ändert den Status eines Incidents und erstellt eine Aktivität', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Status-Test',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'outage',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = UpdateIncidentStatusRequest::create('/admin/payment-dashboard/'.$incident->id.'/status', 'PATCH', [
|
||||||
|
'status' => 'resolved',
|
||||||
|
]);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
|
||||||
|
$controller = new PaymentDashboardController;
|
||||||
|
$controller->updateStatus($request, $incident);
|
||||||
|
|
||||||
|
$incident->refresh();
|
||||||
|
expect($incident->status)->toBe('resolved');
|
||||||
|
expect($incident->resolved_at)->not->toBeNull();
|
||||||
|
|
||||||
|
$statusActivity = IncidentActivity::where('incident_id', $incident->id)
|
||||||
|
->where('type', 'status_change')
|
||||||
|
->first();
|
||||||
|
expect($statusActivity)->not->toBeNull();
|
||||||
|
expect($statusActivity->title)->toContain('Gelöst');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt resolved_at nicht wenn Status in_progress ist', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Resolved At Test',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'other',
|
||||||
|
'severity' => 'low',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = UpdateIncidentStatusRequest::create('', 'PATCH', ['status' => 'in_progress']);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
(new PaymentDashboardController)->updateStatus($request, $incident);
|
||||||
|
|
||||||
|
expect($incident->fresh()->resolved_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Controller: Aktivität hinzufügen ────────────────────────────────────────
|
||||||
|
|
||||||
|
it('fügt eine Aktivität zu einem Incident hinzu', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Aktivitäts-Test',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'payment_failure',
|
||||||
|
'severity' => 'medium',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = AddIncidentActivityRequest::create('', 'POST', [
|
||||||
|
'type' => 'email',
|
||||||
|
'title' => 'Mail an PAYONE gesendet',
|
||||||
|
'content' => 'Ticket eröffnet, Antwort abwarten.',
|
||||||
|
]);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
|
||||||
|
(new PaymentDashboardController)->addActivity($request, $incident);
|
||||||
|
|
||||||
|
$activity = IncidentActivity::where('incident_id', $incident->id)
|
||||||
|
->where('type', 'email')
|
||||||
|
->first();
|
||||||
|
expect($activity)->not->toBeNull();
|
||||||
|
expect($activity->title)->toBe('Mail an PAYONE gesendet');
|
||||||
|
expect($activity->author)->toBe($admin->name ?? 'System');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt Incident automatisch auf in_progress wenn Aktivität bei offenem Incident hinzugefügt wird', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$incident = PaymentIncident::create([
|
||||||
|
'title' => 'Auto-Status-Test',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'other',
|
||||||
|
'severity' => 'low',
|
||||||
|
'status' => 'open',
|
||||||
|
'detected_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = AddIncidentActivityRequest::create('', 'POST', ['type' => 'note', 'title' => 'Erste Notiz']);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
|
||||||
|
(new PaymentDashboardController)->addActivity($request, $incident);
|
||||||
|
|
||||||
|
expect($incident->fresh()->status)->toBe('in_progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validiert Pflichtfelder in UpdateIncidentStatusRequest', function () {
|
||||||
|
$rules = (new UpdateIncidentStatusRequest)->rules();
|
||||||
|
$validator = Validator::make([], $rules);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
expect($validator->errors()->has('status'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validiert ungültigen Status in UpdateIncidentStatusRequest', function () {
|
||||||
|
$rules = (new UpdateIncidentStatusRequest)->rules();
|
||||||
|
$validator = Validator::make(['status' => 'deleted'], $rules);
|
||||||
|
|
||||||
|
expect($validator->fails())->toBeTrue();
|
||||||
|
expect($validator->errors()->has('status'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cache Invalidierung ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('invalidiert den Cache nach Incident-Anlage', function () {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
Cache::put('open_incident_count', 99, 60);
|
||||||
|
|
||||||
|
$request = StorePaymentIncidentRequest::create('', 'POST', [
|
||||||
|
'title' => 'Cache-Test',
|
||||||
|
'provider' => 'payone',
|
||||||
|
'type' => 'other',
|
||||||
|
'severity' => 'low',
|
||||||
|
'detected_at' => now()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$request->setContainer(app());
|
||||||
|
$request->validateResolved();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new PaymentDashboardController)->store($request);
|
||||||
|
} catch (\Symfony\Component\Routing\Exception\RouteNotFoundException $e) {
|
||||||
|
// Domain-spezifische Routen sind im Test-Kontext nicht registriert – erwartet.
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Cache::has('open_incident_count'))->toBeFalse();
|
||||||
|
});
|
||||||
169
tests/Feature/WizardPaymentCartTest.php
Normal file
169
tests/Feature/WizardPaymentCartTest.php
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard-Payment: Da Radio Buttons nur eine Produktauswahl erlauben,
|
||||||
|
* darf nach dem Absenden des Formulars immer nur exakt ein Produkt
|
||||||
|
* im Warenkorb liegen — auch wenn der Nutzer per Browser-Zurück-Button
|
||||||
|
* mehrfach einreicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Schema::connection('sqlite')->table('products', function ($table) {
|
||||||
|
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
|
||||||
|
$table->string('slug')->nullable();
|
||||||
|
}
|
||||||
|
if (! Schema::connection('sqlite')->hasColumn('products', 'free_shipping_consultant')) {
|
||||||
|
$table->boolean('free_shipping_consultant')->default(false)->nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWizardProduct(string $name, float $price = 69.90, int $pos = 1): Product
|
||||||
|
{
|
||||||
|
return Product::forceCreate([
|
||||||
|
'name' => $name,
|
||||||
|
'title' => $name,
|
||||||
|
'price' => $price,
|
||||||
|
'tax' => 19.00,
|
||||||
|
'active' => true,
|
||||||
|
'is_membership_only' => false,
|
||||||
|
'pos' => $pos,
|
||||||
|
'show_at' => 3,
|
||||||
|
'show_on' => '["7","8"]',
|
||||||
|
'identifier' => 'show_order',
|
||||||
|
'action' => '["0","1"]',
|
||||||
|
'no_commission' => false,
|
||||||
|
'no_free_shipping' => false,
|
||||||
|
'free_shipping_consultant' => false,
|
||||||
|
'weight' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
'slug' => \Illuminate\Support\Str::slug($name).'-'.uniqid(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('entfernt vorhandene Produkte aus dem Wizard-Warenkorb wenn ein neues ausgewaehlt wird', function () {
|
||||||
|
$productA = makeWizardProduct('Starter-Paket A', 49.90, 1);
|
||||||
|
$productB = makeWizardProduct('Starter-Paket B', 99.90, 2);
|
||||||
|
|
||||||
|
$instance = 'shopping';
|
||||||
|
|
||||||
|
\Yard::instance($instance)->destroy();
|
||||||
|
|
||||||
|
// Produkt A ist bereits im Warenkorb (simuliert den Zustand nach erstem Absenden)
|
||||||
|
\Yard::instance($instance)->add(
|
||||||
|
$productA->id,
|
||||||
|
$productA->name,
|
||||||
|
1,
|
||||||
|
$productA->price,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'slug' => $productA->slug,
|
||||||
|
'weight' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
'no_commission' => false,
|
||||||
|
'no_free_shipping' => false,
|
||||||
|
'free_shipping_consultant' => false,
|
||||||
|
'show_on' => $productA->show_on,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(\Yard::instance($instance)->count())->toBe(1);
|
||||||
|
|
||||||
|
// Nutzer geht zurück und wählt Produkt B — die neue Logik soll Produkt A entfernen
|
||||||
|
$cartItemB = \Yard::instance($instance)->add(
|
||||||
|
$productB->id,
|
||||||
|
$productB->name,
|
||||||
|
1,
|
||||||
|
$productB->price,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'slug' => $productB->slug,
|
||||||
|
'weight' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
'no_commission' => false,
|
||||||
|
'no_free_shipping' => false,
|
||||||
|
'free_shipping_consultant' => false,
|
||||||
|
'show_on' => $productB->show_on,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schutzlogik aus WizardController::storePayment()
|
||||||
|
foreach (\Yard::instance($instance)->content() as $existingItem) {
|
||||||
|
if ($existingItem->rowId !== $cartItemB->rowId) {
|
||||||
|
\Yard::instance($instance)->remove($existingItem->rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(\Yard::instance($instance)->count())->toBe(1);
|
||||||
|
expect(\Yard::instance($instance)->content()->first()->id)->toBe($productB->id);
|
||||||
|
|
||||||
|
\Yard::instance($instance)->destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaelt das Produkt bei qty=1 wenn dasselbe Produkt erneut abgesendet wird', function () {
|
||||||
|
$product = makeWizardProduct('Starter-Paket A', 49.90, 1);
|
||||||
|
|
||||||
|
$instance = 'shopping';
|
||||||
|
|
||||||
|
\Yard::instance($instance)->destroy();
|
||||||
|
|
||||||
|
\Yard::instance($instance)->add(
|
||||||
|
$product->id,
|
||||||
|
$product->name,
|
||||||
|
1,
|
||||||
|
$product->price,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'slug' => $product->slug,
|
||||||
|
'weight' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
'no_commission' => false,
|
||||||
|
'no_free_shipping' => false,
|
||||||
|
'free_shipping_consultant' => false,
|
||||||
|
'show_on' => $product->show_on,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zweites Absenden desselben Produkts
|
||||||
|
$cartItemAgain = \Yard::instance($instance)->add(
|
||||||
|
$product->id,
|
||||||
|
$product->name,
|
||||||
|
1,
|
||||||
|
$product->price,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'slug' => $product->slug,
|
||||||
|
'weight' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
'no_commission' => false,
|
||||||
|
'no_free_shipping' => false,
|
||||||
|
'free_shipping_consultant' => false,
|
||||||
|
'show_on' => $product->show_on,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($cartItemAgain->qty > 1) {
|
||||||
|
\Yard::instance($instance)->update($cartItemAgain->rowId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (\Yard::instance($instance)->content() as $existingItem) {
|
||||||
|
if ($existingItem->rowId !== $cartItemAgain->rowId) {
|
||||||
|
\Yard::instance($instance)->remove($existingItem->rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(\Yard::instance($instance)->count())->toBe(1);
|
||||||
|
expect(\Yard::instance($instance)->get($cartItemAgain->rowId)->qty)->toBe(1);
|
||||||
|
|
||||||
|
\Yard::instance($instance)->destroy();
|
||||||
|
});
|
||||||
|
|
@ -3,3 +3,4 @@
|
||||||
uses(Tests\TestCase::class)->in('Feature/Incentive');
|
uses(Tests\TestCase::class)->in('Feature/Incentive');
|
||||||
uses(Tests\TestCase::class)->in('Feature/Sys');
|
uses(Tests\TestCase::class)->in('Feature/Sys');
|
||||||
uses(Tests\TestCase::class)->in('Unit/Incentive');
|
uses(Tests\TestCase::class)->in('Unit/Incentive');
|
||||||
|
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue