14-04-2026
This commit is contained in:
parent
f58c709945
commit
0f82fea88a
72 changed files with 7414 additions and 148 deletions
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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue