aether-framework/resources/[aether]/aether-bank/html/index.html
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

939 lines
51 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Fleeca — Bankautomat</title>
<style>
/* ===================== Design-Tokens ===================== */
:root {
--page-bg: #070a09;
/* ATM-Bildschirm (bewusst durchgängig dunkel — ein Gerätescreen) */
--scr-fg: #eaf3ee;
--scr-muted: #85a196;
--scr-surface: #12241d;
--scr-surface2: #182e25;
--scr-border: rgba(255,255,255,0.08);
--scr-accent: #2fd39a;
--scr-accent-soft: rgba(47,211,154,0.14);
--scr-red: #f0708a;
--scr-amber: #f5b23d;
--scr-blue: #7c9cff;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 15% 10%, rgba(47,211,154,0.10), transparent 55%),
radial-gradient(1000px 800px at 90% 90%, rgba(124,156,255,0.08), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #cdd6d1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 22px; padding: 40px 18px; overflow-x: hidden;
}
/* ambiente, leicht bewegte Deko im Hintergrund (durch das Display sichtbar) */
.ambient { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.ambient .blob { position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.5; }
.ambient .b1 { width: 420px; height: 420px; background: #1f6b52; top: -120px; left: -80px; }
.ambient .b2 { width: 380px; height: 380px; background: #2a3f7a; bottom: -140px; right: -60px; }
.ambient .word { position: absolute; inset: 0; display: grid; place-items: center;
font-size: 22vw; font-weight: 800; letter-spacing: -0.04em; color: rgba(255,255,255,0.02);
user-select: none; }
.stage-hint { position: relative; z-index: 2; font-size: 13px; color: #6f7d77; text-align: center; max-width: 560px; }
.stage-hint b { color: #aebbb5; font-weight: 600; }
/* ===================== Gerät (Tablet) ===================== */
.atm {
position: relative; z-index: 3;
width: min(1060px, 95vw);
aspect-ratio: 16 / 10;
background: linear-gradient(150deg, #23282b, #0c0f11 70%);
border-radius: 30px;
padding: 16px;
box-shadow:
0 2px 3px rgba(255,255,255,0.10) inset,
0 0 0 2px #04070a,
0 50px 90px -30px rgba(0,0,0,0.85);
/* Kernanforderung: verlässt die Maus das Display, wird es transparent */
opacity: 0.32;
transition: opacity .45s ease, box-shadow .45s ease;
}
.atm:hover {
opacity: 1;
box-shadow:
0 2px 3px rgba(255,255,255,0.12) inset,
0 0 0 2px #04070a,
0 60px 110px -30px rgba(0,0,0,0.9),
0 30px 80px -40px rgba(47,211,154,0.35);
}
/* Kamera-Punkt oben mittig */
.atm::before {
content: ""; position: absolute; top: 24px; left: 50%; transform: translateX(-50%);
width: 7px; height: 7px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #3a5568, #05070c);
z-index: 5;
}
/* Fleeca-Schriftzug auf dem Rahmen */
.atm::after {
content: "FLEECA"; position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
font-size: 11px; letter-spacing: 0.5em; font-weight: 700; color: rgba(255,255,255,0.22);
}
.screen {
position: relative; width: 100%; height: 100%; border-radius: 18px; overflow: hidden;
background:
radial-gradient(120% 80% at 20% 0%, #123a2c 0%, transparent 55%),
radial-gradient(120% 90% at 100% 100%, #10233a 0%, transparent 50%),
linear-gradient(160deg, #0b1a15, #060d0a);
color: var(--scr-fg); font-size: 15px;
display: flex; flex-direction: column;
}
/* ===================== Login / PIN ===================== */
.view { position: absolute; inset: 0; display: none; flex-direction: column; }
.view.active { display: flex; }
.view.fade { animation: viewIn .35s ease; }
@keyframes viewIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .view.fade { animation: none; } }
.login { align-items: stretch; justify-content: center; height: 100%;
display: grid; grid-template-columns: 1.05fr 1fr; }
.login-left { padding: 46px; display: flex; flex-direction: column; justify-content: center; gap: 20px;
border-right: 1px solid var(--scr-border);
background: linear-gradient(160deg, rgba(47,211,154,0.06), transparent); }
.brand { display: flex; align-items: center; gap: 12px; }
.brand .mark { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center;
background: linear-gradient(150deg, #2fd39a, #159c74); box-shadow: 0 8px 22px rgba(47,211,154,0.45); }
.brand .name { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.brand .name span { color: var(--scr-accent); }
.login-left h1 { font-size: 30px; font-weight: 700; margin: 6px 0 0; letter-spacing: -0.02em; text-wrap: balance; }
.login-left p { color: var(--scr-muted); font-size: 15px; line-height: 1.6; margin: 0; max-width: 340px; }
.bank-card {
margin-top: 8px; width: 300px; max-width: 100%; aspect-ratio: 1.6 / 1; border-radius: 18px;
padding: 20px; color: #fff; position: relative; overflow: hidden;
background: linear-gradient(135deg, #1c3b32 0%, #0e211b 70%);
border: 1px solid rgba(47,211,154,0.3); box-shadow: 0 20px 40px -20px rgba(0,0,0,0.7);
}
.bank-card::after { content: ""; position: absolute; width: 200px; height: 200px; border-radius: 50%;
background: radial-gradient(circle, rgba(47,211,154,0.35), transparent 70%); top: -70px; right: -50px; }
.bank-card .chip { width: 38px; height: 28px; border-radius: 6px; background: linear-gradient(135deg,#e9d9a0,#b89b53); margin-bottom: 26px; }
.bank-card .num { font-family: ui-monospace, monospace; letter-spacing: 0.18em; font-size: 15px; }
.bank-card .who { margin-top: 12px; font-size: 12px; opacity: 0.85; text-transform: uppercase; letter-spacing: 0.06em; }
.login-right { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; }
.pin-title { font-size: 19px; font-weight: 700; }
.pin-sub { font-size: 13.5px; color: var(--scr-muted); margin-top: 4px; margin-bottom: 22px; text-align: center; }
.pin-dots { display: flex; gap: 16px; margin-bottom: 26px; }
.pin-dots.shake { animation: shake .4s; }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-8px)} 40%,80%{transform:translateX(8px)} }
.pin-dot { width: 15px; height: 15px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.4); transition: .12s; }
.pin-dot.on { background: var(--scr-accent); border-color: var(--scr-accent); box-shadow: 0 0 12px rgba(47,211,154,0.6); }
.keypad { display: grid; grid-template-columns: repeat(3, 78px); gap: 14px; }
.key { height: 68px; border-radius: 16px; border: 1px solid var(--scr-border);
background: var(--scr-surface2); color: var(--scr-fg); font-size: 24px; font-weight: 500;
cursor: pointer; font-family: inherit; transition: .1s; }
.key:hover { border-color: var(--scr-accent); }
.key:active { background: var(--scr-accent); color: #06231a; transform: scale(0.96); }
.key.wide { font-size: 16px; font-weight: 600; }
.key.enter { background: var(--scr-accent); color: #06231a; border: none; }
/* ===================== App-Shell ===================== */
.shell { display: grid; grid-template-columns: 216px 1fr; grid-template-rows: auto 1fr; height: 100%;
grid-template-areas: "brand top" "nav main"; }
.shell-brand { grid-area: brand; padding: 18px 20px; border-right: 1px solid var(--scr-border);
border-bottom: 1px solid var(--scr-border); display: flex; align-items: center; gap: 10px; }
.shell-brand .mark { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
background: linear-gradient(150deg, #2fd39a, #159c74); }
.shell-brand .name { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; }
.shell-brand .name span { color: var(--scr-accent); }
.shell-top { grid-area: top; padding: 0 26px; border-bottom: 1px solid var(--scr-border);
display: flex; align-items: center; gap: 20px; }
.shell-top .greet { font-weight: 600; }
.shell-top .greet small { display: block; color: var(--scr-muted); font-weight: 400; font-size: 12px; }
.shell-top .clock { margin-left: auto; text-align: right; font-variant-numeric: tabular-nums; }
.shell-top .clock .t { font-size: 18px; font-weight: 700; }
.shell-top .clock .d { font-size: 11.5px; color: var(--scr-muted); }
.top-balance { text-align: right; padding-left: 20px; border-left: 1px solid var(--scr-border); }
.top-balance .l { font-size: 11px; color: var(--scr-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.top-balance .v { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nav { grid-area: nav; border-right: 1px solid var(--scr-border); padding: 14px 12px; display: flex; flex-direction: column; gap: 4px; }
.nav button { display: flex; align-items: center; gap: 12px; padding: 11px 13px; border-radius: 12px;
background: none; border: none; color: var(--scr-muted); font-family: inherit; font-size: 14.5px; font-weight: 600;
cursor: pointer; text-align: left; transition: .12s; }
.nav button svg { width: 20px; height: 20px; flex-shrink: 0; }
.nav button:hover { background: var(--scr-surface); color: var(--scr-fg); }
.nav button.active { background: var(--scr-accent-soft); color: var(--scr-accent); }
.nav .spacer { flex: 1; }
.nav .logout { color: var(--scr-red); }
.nav .logout:hover { background: rgba(240,112,138,0.12); color: var(--scr-red); }
.main { grid-area: main; overflow-y: auto; padding: 24px 26px; }
.main::-webkit-scrollbar { width: 8px; }
.main::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; gap: 16px; }
.page-head h2 { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-head p { margin: 4px 0 0; color: var(--scr-muted); font-size: 13.5px; }
/* Karten / Kacheln */
.grid { display: grid; gap: 16px; }
.card { background: var(--scr-surface); border: 1px solid var(--scr-border); border-radius: 18px; padding: 18px; }
.card.pad0 { padding: 0; }
.stat { display: flex; flex-direction: column; gap: 6px; }
.stat .l { font-size: 12px; color: var(--scr-muted); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; }
.stat .v { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
.stat .d { font-size: 12.5px; font-weight: 600; }
.up { color: var(--scr-accent); } .down { color: var(--scr-red); }
.balance-hero { background: linear-gradient(140deg, #14352a, #0c1f19); border: 1px solid rgba(47,211,154,0.25);
border-radius: 20px; padding: 22px 24px; position: relative; overflow: hidden; }
.balance-hero .l { font-size: 12px; color: #9fd8c4; letter-spacing: 0.06em; text-transform: uppercase; }
.balance-hero .v { font-size: 44px; font-weight: 800; margin: 4px 0; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.balance-hero .iban { font-family: ui-monospace, monospace; letter-spacing: 0.12em; color: #9fd8c4; font-size: 13px; }
/* Buttons & Inputs */
.btn { background: var(--scr-accent); color: #06231a; border: none; border-radius: 13px; padding: 14px 20px;
font-size: 15px; font-weight: 700; cursor: pointer; font-family: inherit; transition: .12s; }
.btn:hover { filter: brightness(1.07); }
.btn:active { transform: scale(0.98); }
.btn.ghost { background: var(--scr-surface2); color: var(--scr-fg); border: 1px solid var(--scr-border); }
.btn.red { background: var(--scr-red); color: #2a0910; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.field-label { font-size: 12px; color: var(--scr-muted); font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; margin: 14px 2px 7px; display: block; }
.input { width: 100%; background: var(--scr-surface2); border: 1px solid var(--scr-border); border-radius: 12px;
padding: 14px 15px; color: var(--scr-fg); font-size: 16px; font-family: inherit; outline: none;
font-variant-numeric: tabular-nums; }
.input:focus { border-color: var(--scr-accent); }
/* Betrags-Presets */
.amount-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.amount-opt { padding: 20px 12px; border-radius: 14px; border: 1px solid var(--scr-border);
background: var(--scr-surface2); color: var(--scr-fg); font-size: 22px; font-weight: 700; cursor: pointer;
font-family: inherit; font-variant-numeric: tabular-nums; transition: .12s; }
.amount-opt:hover { border-color: var(--scr-accent); }
.amount-opt.sel { background: var(--scr-accent); color: #06231a; border-color: var(--scr-accent); }
.amount-display { text-align: center; font-size: 40px; font-weight: 800; font-variant-numeric: tabular-nums; padding: 6px 0 4px; }
/* Transaktionen / Kontoauszug */
.tx { display: flex; align-items: center; gap: 14px; padding: 13px 6px; border-bottom: 1px solid var(--scr-border); }
.tx:last-child { border-bottom: none; }
.tx .ic { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; flex-shrink: 0;
background: var(--scr-surface2); font-size: 18px; }
.tx .grow { flex: 1; min-width: 0; }
.tx .t { font-weight: 600; }
.tx .m { font-size: 12.5px; color: var(--scr-muted); }
.tx .amt { font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; }
.tx .amt.plus { color: var(--scr-accent); }
.tx .bal { font-size: 12px; color: var(--scr-muted); text-align: right; font-variant-numeric: tabular-nums; }
.seg { display: inline-flex; background: var(--scr-surface2); border-radius: 11px; padding: 3px; gap: 2px; }
.seg button { border: none; background: none; color: var(--scr-muted); font-weight: 600; padding: 7px 14px;
border-radius: 8px; cursor: pointer; font-family: inherit; font-size: 13px; transition: .12s; }
.seg button.active { background: var(--scr-accent); color: #06231a; }
.pill { font-size: 11px; font-weight: 800; padding: 4px 10px; border-radius: 999px; }
.pill.green { background: var(--scr-accent-soft); color: var(--scr-accent); }
.pill.red { background: rgba(240,112,138,0.14); color: var(--scr-red); }
/* ===================== Aktien-artiges Chart ===================== */
.chart-wrap { position: relative; width: 100%; }
.chart-wrap svg { display: block; width: 100%; }
.chart-cross { position: absolute; top: 0; width: 1px; background: rgba(255,255,255,0.28); pointer-events: none;
display: none; }
.chart-cursor { position: absolute; width: 12px; height: 12px; border-radius: 50%; background: var(--scr-accent);
border: 2px solid #0b1a15; transform: translate(-50%, -50%); pointer-events: none; display: none;
box-shadow: 0 0 0 4px rgba(47,211,154,0.25); }
.chart-tip { position: absolute; pointer-events: none; display: none; transform: translate(-50%, -120%);
background: rgba(8,18,14,0.96); border: 1px solid var(--scr-border); border-radius: 10px; padding: 7px 11px;
font-size: 12.5px; white-space: nowrap; z-index: 4; box-shadow: 0 10px 30px -8px rgba(0,0,0,0.7); }
.chart-tip .v { font-weight: 800; font-variant-numeric: tabular-nums; }
.chart-tip .d { color: var(--scr-muted); font-size: 11px; }
/* Dispensing / Bestätigung */
.flash { position: absolute; inset: 0; z-index: 30; display: none; flex-direction: column;
align-items: center; justify-content: center; text-align: center; padding: 40px; gap: 16px;
background: radial-gradient(120% 80% at 50% 0%, #123a2c, #060d0a); }
.flash.show { display: flex; }
.flash .big { font-size: 66px; }
.flash h3 { font-size: 26px; font-weight: 800; margin: 0; }
.flash p { color: var(--scr-muted); margin: 0; max-width: 420px; }
.cash { width: 130px; height: 74px; border-radius: 10px; position: relative; margin-bottom: 6px;
background: linear-gradient(160deg,#3ea883,#1c6b52); border: 1px solid rgba(255,255,255,0.25);
animation: cashOut .8s ease; }
.cash::after { content: "€"; position: absolute; inset: 0; display: grid; place-items: center; font-size: 30px; font-weight: 800; color: rgba(255,255,255,0.85); }
@keyframes cashOut { from { transform: translateY(-30px) scale(0.9); opacity: 0; } to { transform: none; opacity: 1; } }
.spinner { width: 46px; height: 46px; border-radius: 50%; border: 4px solid var(--scr-surface2);
border-top-color: var(--scr-accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.toast { position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: rgba(8,18,14,0.96); border: 1px solid var(--scr-border); color: var(--scr-fg);
padding: 12px 20px; border-radius: 13px; font-size: 13.5px; font-weight: 600; z-index: 40;
opacity: 0; pointer-events: none; transition: .3s; box-shadow: 0 12px 30px -8px rgba(0,0,0,0.7); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 720px) {
.login { grid-template-columns: 1fr; }
.login-left { display: none; }
.shell { grid-template-columns: 64px 1fr; }
.shell-brand .name, .nav button span { display: none; }
.nav button { justify-content: center; }
}
</style>
<div class="ambient">
<div class="blob b1"></div><div class="blob b2"></div>
<div class="word">FLEECA</div>
</div>
<!-- Nur für die eigenständige Browser-Ansicht; in FiveM ausgeblendet -->
<div class="stage-hint" id="browser-hinweis">
Bewege die Maus auf den Automaten, um ihn zu aktivieren — <b>verlässt du das Display, wird es transparent</b>.
Test-PIN: <b>1234</b>
</div>
<!-- ===================== Bankautomat ===================== -->
<div class="atm">
<div class="screen">
<!-- LOGIN / PIN-Eingabe -->
<div class="view active" id="v-login">
<div class="login">
<div class="login-left">
<div class="brand"><span class="mark">
<svg width="24" height="24" viewBox="0 0 24 24" fill="#06231a"><path d="M12 2 2 7v2h20V7L12 2ZM4 10v7H2v3h20v-3h-2v-7h-3v7h-3v-7h-3v7H7v-7H4Z"/></svg>
</span><span class="name">Fleeca<span>.</span></span></div>
<h1>Willkommen am Automaten</h1>
<p>Stecke deine Karte ein und gib deine PIN ein, um auf dein Konto zuzugreifen.</p>
<div class="bank-card">
<div class="chip"></div>
<div class="num" id="card-num">•••• •••• •••• 2118</div>
<div class="who" id="card-who">Max Weber</div>
</div>
</div>
<div class="login-right">
<div class="pin-title">PIN eingeben</div>
<div class="pin-sub">4-stelligen Code zur Freigabe eingeben</div>
<div class="pin-dots" id="login-dots"></div>
<div class="keypad" id="login-pad"></div>
</div>
</div>
</div>
<!-- APP-SHELL (nach Login) -->
<div class="view" id="v-app">
<div class="shell">
<div class="shell-brand"><span class="mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#06231a"><path d="M12 2 2 7v2h20V7L12 2ZM4 10v7H2v3h20v-3h-2v-7h-3v7h-3v-7h-3v7H7v-7H4Z"/></svg>
</span><span class="name">Fleeca<span>.</span></span></div>
<div class="shell-top">
<div class="greet">Guten Tag, Max Weber<small>Girokonto · verfügbar</small></div>
<div class="top-balance"><div class="l">Kontostand</div><div class="v" id="top-bal">48.320 €</div></div>
<div class="clock"><div class="t" id="atm-time">14:32</div><div class="d" id="atm-date">Di, 21. Juli 2026</div></div>
</div>
<div class="nav" id="atm-nav"></div>
<div class="main" id="atm-main"></div>
</div>
</div>
<!-- Overlay: Aktion läuft / Bestätigung -->
<div class="flash" id="flash"></div>
<div class="toast" id="toast"></div>
</div>
</div>
<script>
"use strict";
/* =====================================================================
KONTO-DATEN — bewusst getrennt vom UI (später an DB/Server anbinden)
===================================================================== */
/* Kontodaten kommen vom Server. Die PIN ist bewusst NICHT enthalten —
sie wird ausschließlich serverseitig geprüft und nie übertragen. */
const konto = {
inhaber: "—",
iban: "—",
saldo: 0,
bargeld: 0,
transaktionen: []
};
/* Kontostand-Verlauf aus den ECHTEN Buchungen ableiten.
Jede Buchung trägt den Kontostand danach (Spalte `balance`), daraus
entsteht die Kurve. Gibt es zu wenige Buchungen, bleibt sie flach. */
function erzeugeVerlauf(maxPunkte, labelFn) {
// Buchungen kommen neueste zuerst -> für die Kurve umdrehen
const buchungen = konto.transaktionen.slice(0, maxPunkte).reverse();
if (buchungen.length < 2) {
// Zu wenig Historie: flache Linie auf dem aktuellen Stand
const werte = [konto.saldo, konto.saldo];
return { werte, labels: ["", "jetzt"] };
}
const werte = buchungen.map(t => Math.round(t.saldo || 0));
werte[werte.length - 1] = konto.saldo; // aktuell gültiger Stand am Ende
const labels = buchungen.map((t, i) =>
labelFn ? labelFn(t, i, buchungen.length) : "");
return { werte, labels };
}
/* Drei Zeiträume — hier als „letzte N Buchungen", weil die Kurve aus
echten Buchungen entsteht und nicht aus erfundenen Tageswerten. */
const verlaeufe = {
"7T": () => erzeugeVerlauf(10, (t, i, n) => (i === 0 || i === n - 1) ? (t.zeit || "").slice(5, 10) : ""),
"30T": () => erzeugeVerlauf(30, (t, i, n) => (i % 5 === 0 || i === n - 1) ? (t.zeit || "").slice(5, 10) : ""),
"12M": () => erzeugeVerlauf(60, (t, i, n) => (i % 10 === 0 || i === n - 1) ? (t.zeit || "").slice(0, 7) : "")
};
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
// Euro deutsch formatieren
const euro = betrag => betrag.toLocaleString("de-DE", { minimumFractionDigits: 0 }) + " €";
// gegen HTML-Injektion absichern
function sicher(text) { const d = document.createElement("div"); d.textContent = text; return d.innerHTML; }
// kurze Einblend-Nachricht
let toastTimer = null;
function toast(text) {
const t = $("toast"); t.textContent = text; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2200);
}
// Kontostand-Anzeigen (oben) aktualisieren
function aktualisiereSaldo() { $("top-bal").textContent = euro(konto.saldo); }
/* =====================================================================
UHR
===================================================================== */
function aktualisiereUhr() {
const j = new Date();
const hh = String(j.getHours()).padStart(2, "0"), mm = String(j.getMinutes()).padStart(2, "0");
if ($("atm-time")) $("atm-time").textContent = hh + ":" + mm;
const wt = ["So","Mo","Di","Mi","Do","Fr","Sa"][j.getDay()];
const mo = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"][j.getMonth()];
if ($("atm-date")) $("atm-date").textContent = wt + ", " + j.getDate() + ". " + mo + " " + j.getFullYear();
}
aktualisiereUhr(); setInterval(aktualisiereUhr, 1000);
/* =====================================================================
PIN-TASTENFELD (Login & PIN-Änderung)
===================================================================== */
// Rendert Ziffernpunkte + Tastenfeld in Container; ruft onFertig(code, reset) bei 4 Ziffern.
function bauePinPad(dotsEl, padEl, onFertig) {
let code = "";
const zeichneDots = () => {
dotsEl.innerHTML = [0,1,2,3].map(i => '<span class="pin-dot' + (i < code.length ? ' on' : '') + '"></span>').join("");
};
const tasten = ["1","2","3","4","5","6","7","8","9","clear","0","del"];
padEl.innerHTML = tasten.map(t => {
if (t === "clear") return '<button class="key wide" data-k="clear">C</button>';
if (t === "del") return '<button class="key wide" data-k="del">⌫</button>';
return '<button class="key" data-k="' + t + '">' + t + '</button>';
}).join("");
padEl.querySelectorAll(".key").forEach(b => b.addEventListener("click", () => {
const k = b.dataset.k;
if (k === "del") code = code.slice(0, -1);
else if (k === "clear") code = "";
else if (code.length < 4) code += k;
zeichneDots();
if (code.length === 4) setTimeout(() => onFertig(code, () => { code = ""; zeichneDots(); }), 150);
}));
zeichneDots();
}
/* =====================================================================
LOGIN
===================================================================== */
// Wird gesetzt, damit die Antwort des Servers das Eingabefeld leeren kann
let pinZuruecksetzen = null;
function initLogin() {
$("card-who").textContent = konto.inhaber;
bauePinPad($("login-dots"), $("login-pad"), (code, reset) => {
pinZuruecksetzen = reset;
if (!IST_FIVEM) {
// Browser-Ansicht ohne Server: Test-PIN 1234
if (code === "1234") { reset(); zeigeView("v-app"); starteApp(); }
else { pinFehlgeschlagen(); }
return;
}
// Die PIN wird ausschließlich auf dem Server geprüft
nui("pinPruefen", { pin: code });
});
}
/* Fehlgeschlagene PIN-Eingabe anzeigen */
function pinFehlgeschlagen() {
$("login-dots").classList.add("shake");
setTimeout(() => $("login-dots").classList.remove("shake"), 450);
toast("Falsche PIN");
if (pinZuruecksetzen) pinZuruecksetzen();
}
// Zwischen Login und App umschalten
function zeigeView(id) {
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
const v = $(id); v.classList.add("active"); v.classList.add("fade");
setTimeout(() => v.classList.remove("fade"), 400);
}
/* =====================================================================
NAVIGATION / APP-START
===================================================================== */
const navPunkte = [
{ id: "uebersicht", label: "Übersicht", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>' },
{ id: "auszahlen", label: "Auszahlen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7 7 7-7"/></svg>' },
{ id: "einzahlen", label: "Einzahlen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7-7 7 7"/></svg>' },
{ id: "ueberweisen", label: "Überweisen", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>' },
{ id: "auszug", label: "Kontoauszug", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>' },
{ id: "statistik", label: "Statistiken", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15l4-5 4 3 6-8"/><path d="M4 20h16"/></svg>' },
{ id: "pin", label: "PIN ändern", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>' }
];
function starteApp() {
// Navigation aufbauen
const nav = $("atm-nav");
nav.innerHTML = navPunkte.map(p =>
'<button data-sec="' + p.id + '">' + p.icon + '<span>' + p.label + '</span></button>').join("") +
'<div class="spacer"></div>' +
'<button class="logout" data-sec="logout"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg><span>Abmelden</span></button>';
nav.querySelectorAll("[data-sec]").forEach(b => b.addEventListener("click", () => {
if (b.dataset.sec === "logout") { abmelden(); return; }
setzeSektion(b.dataset.sec);
}));
aktualisiereSaldo();
setzeSektion("uebersicht");
}
function abmelden() {
zeigeView("v-login");
toast("Abgemeldet — Karte entnommen");
}
// aktive Sektion rendern
function setzeSektion(sec) {
$("atm-nav").querySelectorAll("[data-sec]").forEach(b =>
b.classList.toggle("active", b.dataset.sec === sec));
const renderer = sektionen[sec];
if (renderer) renderer();
}
/* =====================================================================
SEKTIONEN
===================================================================== */
const sektionen = {};
// ---- Übersicht (Dashboard) ----
sektionen.uebersicht = function () {
const einnahmen = konto.transaktionen.filter(t => t.betrag > 0).reduce((s, t) => s + t.betrag, 0);
const ausgaben = konto.transaktionen.filter(t => t.betrag < 0).reduce((s, t) => s + t.betrag, 0);
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Übersicht</h2><p>Dein Konto auf einen Blick</p></div>' +
'<div class="seg" id="ueb-range"><button data-r="7T">7T</button><button data-r="30T" class="active">30T</button><button data-r="12M">12M</button></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1.4fr 1fr;align-items:start">' +
'<div class="card pad0" style="padding:18px">' +
'<div class="balance-hero" style="margin-bottom:16px">' +
'<div class="l">Verfügbarer Kontostand</div><div class="v">' + euro(konto.saldo) + '</div>' +
'<div class="iban">' + sicher(konto.iban) + '</div></div>' +
'<div class="chart-wrap" id="ueb-chart"></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1fr 1fr">' +
statKarte("Einnahmen", euro(einnahmen), "▲ diesen Monat", "up") +
statKarte("Ausgaben", euro(Math.abs(ausgaben)), "▼ diesen Monat", "down") +
'<div class="card" style="grid-column:span 2">' +
'<div class="stat"><div class="l">Letzte Buchungen</div></div>' +
konto.transaktionen.slice(0, 4).map(txZeile).join("") + '</div>' +
'</div>' +
'</div>';
let range = "30T";
const zeichne = () => baueVerlaufChart($("ueb-chart"), verlaeufe[range](), { hoehe: 250 });
zeichne();
$("ueb-range").querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => {
range = b.dataset.r;
$("ueb-range").querySelectorAll("[data-r]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichne();
}));
};
function statKarte(label, wert, delta, cls) {
return '<div class="card stat"><div class="l">' + label + '</div><div class="v">' + wert + '</div>' +
'<div class="d ' + cls + '">' + delta + '</div></div>';
}
function txZeile(t) {
return '<div class="tx"><span class="ic">' + t.icon + '</span>' +
'<div class="grow"><div class="t">' + sicher(t.titel) + '</div><div class="m">' + sicher(t.zeit) + ' · ' + sicher(t.kat) + '</div></div>' +
'<div class="amt ' + (t.betrag > 0 ? "plus" : "") + '">' + (t.betrag > 0 ? "+" : "") + euro(t.betrag) + '</div></div>';
}
// ---- Auszahlen ----
let auszahlBetrag = 0;
sektionen.auszahlen = function () {
auszahlBetrag = 0;
const presets = [20, 50, 100, 200, 500, 1000];
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Auszahlen</h2><p>Betrag wählen — der Automat gibt das Geld aus</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card"><div class="field-label">Schnellauswahl</div>' +
'<div class="amount-grid" id="az-presets">' +
presets.map(p => '<button class="amount-opt" data-a="' + p + '">' + euro(p) + '</button>').join("") + '</div>' +
'<div class="field-label">Eigener Betrag</div>' +
'<input class="input" id="az-custom" type="number" inputmode="numeric" placeholder="0" min="0" step="10">' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Auszahlungsbetrag</div></div>' +
'<div class="amount-display" id="az-display">0 €</div>' +
'<div class="m" style="text-align:center;color:var(--scr-muted);font-size:12.5px;margin-bottom:16px">Verfügbar: ' + euro(konto.saldo) + '</div>' +
'<button class="btn" id="az-go" style="width:100%" disabled>Geld auszahlen</button></div>' +
'</div>';
const setzeBetrag = (b) => {
auszahlBetrag = b || 0;
$("az-display").textContent = euro(auszahlBetrag);
$("az-go").disabled = !(auszahlBetrag > 0);
$("az-presets").querySelectorAll("[data-a]").forEach(x => x.classList.toggle("sel", +x.dataset.a === auszahlBetrag));
};
$("az-presets").querySelectorAll("[data-a]").forEach(b => b.addEventListener("click", () => {
$("az-custom").value = ""; setzeBetrag(+b.dataset.a);
}));
$("az-custom").addEventListener("input", e => setzeBetrag(parseInt(e.target.value, 10)));
$("az-go").addEventListener("click", () => {
if (auszahlBetrag < 1) { toast("Bitte einen Betrag wählen"); return; }
// Deckung prüft der Server — hier nur die Wartemeldung zeigen
zeigeFlash('<div class="spinner"></div><h3>Auszahlung wird verarbeitet…</h3><p>Bitte warten.</p>');
nui("auszahlen", { betrag: auszahlBetrag });
});
};
// ---- Einzahlen ----
sektionen.einzahlen = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Einzahlen</h2><p>Bargeld auf dein Konto einzahlen</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card"><div class="field-label">Betrag (Bargeld)</div>' +
'<input class="input" id="ez-amount" type="number" inputmode="numeric" placeholder="0" min="0" step="10">' +
'<div class="field-label">Schnellauswahl</div>' +
'<div class="amount-grid" id="ez-presets">' +
[50,100,500].map(p => '<button class="amount-opt" data-a="' + p + '">' + euro(p) + '</button>').join("") + '</div>' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Neuer Kontostand</div></div>' +
'<div class="amount-display" id="ez-preview">' + euro(konto.saldo) + '</div>' +
'<button class="btn" id="ez-go" style="width:100%" disabled>Einzahlen</button></div>' +
'</div>';
const lese = () => parseInt($("ez-amount").value, 10) || 0;
const update = () => {
const b = lese();
$("ez-preview").textContent = euro(konto.saldo + b);
$("ez-go").disabled = !(b > 0);
};
$("ez-presets").querySelectorAll("[data-a]").forEach(b => b.addEventListener("click", () => { $("ez-amount").value = b.dataset.a; update(); }));
$("ez-amount").addEventListener("input", update);
$("ez-go").addEventListener("click", () => {
const b = lese(); if (b <= 0) return;
// Ob genug Bargeld dabei ist, prüft der Server
zeigeFlash('<div class="spinner"></div><h3>Einzahlung wird gezählt…</h3><p>Bitte warten.</p>');
nui("einzahlen", { betrag: b });
});
};
// ---- Überweisen ----
sektionen.ueberweisen = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Überweisen</h2><p>Geld an ein anderes Konto senden</p></div></div>' +
'<div class="grid" style="grid-template-columns:1fr 340px;align-items:start">' +
'<div class="card">' +
'<div class="field-label">Empfänger (IBAN)</div><input class="input" id="ue-to" placeholder="z. B. DE12 3456 7890 12">' +
'<div class="field-label">Betrag</div><input class="input" id="ue-amount" type="number" inputmode="numeric" placeholder="0" min="0">' +
'<div class="field-label">Verwendungszweck</div><input class="input" id="ue-note" placeholder="optional">' +
'</div>' +
'<div class="card"><div class="stat"><div class="l">Verfügbar</div><div class="v" style="font-size:22px">' + euro(konto.saldo) + '</div></div>' +
'<div style="height:16px"></div><button class="btn" id="ue-go" style="width:100%">Überweisung senden</button></div>' +
'</div>';
$("ue-go").addEventListener("click", () => {
const to = $("ue-to").value.trim(); const b = parseInt($("ue-amount").value, 10) || 0;
const note = $("ue-note").value.trim();
if (!to) { toast("Bitte Empfänger-IBAN angeben"); return; }
if (b <= 0) { toast("Bitte gültigen Betrag angeben"); return; }
// Empfänger und Deckung prüft der Server
zeigeFlash('<div class="spinner"></div><h3>Überweisung wird gesendet…</h3><p>Bitte warten.</p>');
nui("ueberweisen", { ziel: to, betrag: b, zweck: note });
});
};
// ---- Kontoauszug ----
let auszugFilter = "alle";
sektionen.auszug = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Kontoauszug</h2><p>Alle Buchungen deines Kontos</p></div>' +
'<div class="seg" id="au-filter"><button data-f="alle" class="active">Alle</button><button data-f="ein">Eingang</button><button data-f="aus">Ausgang</button></div>' +
'</div>' +
'<div class="card pad0" style="padding:8px 18px"><div id="au-list"></div></div>';
zeichneAuszug();
$("au-filter").querySelectorAll("[data-f]").forEach(b => b.addEventListener("click", () => {
auszugFilter = b.dataset.f;
$("au-filter").querySelectorAll("[data-f]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichneAuszug();
}));
};
function zeichneAuszug() {
// laufenden Saldo rückwärts berechnen (Auszug zeigt Kontostand nach Buchung)
let lauf = konto.saldo;
const mitSaldo = konto.transaktionen.map(t => { const eintrag = { ...t, saldo: lauf }; lauf -= t.betrag; return eintrag; });
const gefiltert = mitSaldo.filter(t => auszugFilter === "alle" || (auszugFilter === "ein" ? t.betrag > 0 : t.betrag < 0));
$("au-list").innerHTML = gefiltert.length ? gefiltert.map(t =>
'<div class="tx"><span class="ic">' + t.icon + '</span>' +
'<div class="grow"><div class="t">' + sicher(t.titel) + '</div><div class="m">' + sicher(t.zeit) + ' · ' + sicher(t.kat) + '</div></div>' +
'<div><div class="amt ' + (t.betrag > 0 ? "plus" : "") + '">' + (t.betrag > 0 ? "+" : "") + euro(t.betrag) + '</div>' +
'<div class="bal">Saldo: ' + euro(t.saldo) + '</div></div></div>').join("")
: '<div style="text-align:center;color:var(--scr-muted);padding:40px">Keine Buchungen.</div>';
}
// ---- Statistiken ----
sektionen.statistik = function () {
const einnahmen = konto.transaktionen.filter(t => t.betrag > 0).reduce((s, t) => s + t.betrag, 0);
const ausgaben = Math.abs(konto.transaktionen.filter(t => t.betrag < 0).reduce((s, t) => s + t.betrag, 0));
const sparquote = einnahmen > 0 ? Math.round((einnahmen - ausgaben) / einnahmen * 100) : 0;
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>Statistiken</h2><p>Kontoentwicklung im Zeitverlauf</p></div>' +
'<div class="seg" id="st-range"><button data-r="7T">7T</button><button data-r="30T" class="active">30T</button><button data-r="12M">12M</button></div>' +
'</div>' +
'<div class="card" style="margin-bottom:16px">' +
'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">' +
'<div class="stat"><div class="l">Kontostand-Verlauf</div><div class="v" style="font-size:30px">' + euro(konto.saldo) + '</div></div>' +
'<span class="pill green" id="st-delta">▲ 0 %</span></div>' +
'<div class="chart-wrap" id="st-chart"></div>' +
'</div>' +
'<div class="grid" style="grid-template-columns:1fr 1fr 1fr">' +
statKarte("Einnahmen (Monat)", euro(einnahmen), "▲ Zuflüsse", "up") +
statKarte("Ausgaben (Monat)", euro(ausgaben), "▼ Abflüsse", "down") +
statKarte("Sparquote", sparquote + " %", (sparquote >= 0 ? "▲" : "▼") + " Überschuss", sparquote >= 0 ? "up" : "down") +
'</div>';
let range = "30T";
const zeichne = () => {
const daten = verlaeufe[range]();
baueVerlaufChart($("st-chart"), daten, { hoehe: 300 });
const w = daten.werte, delta = w[0] ? Math.round((w[w.length - 1] - w[0]) / Math.abs(w[0]) * 100) : 0;
const d = $("st-delta"); d.textContent = (delta >= 0 ? "▲ +" : "▼ ") + delta + " %";
d.className = "pill " + (delta >= 0 ? "green" : "red");
};
zeichne();
$("st-range").querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => {
range = b.dataset.r;
$("st-range").querySelectorAll("[data-r]").forEach(x => x.classList.remove("active"));
b.classList.add("active"); zeichne();
}));
};
// ---- PIN ändern ----
sektionen.pin = function () {
$("atm-main").innerHTML =
'<div class="page-head"><div><h2>PIN ändern</h2><p>Wähle einen neuen 4-stelligen Code für deine Karte</p></div></div>' +
'<div class="card" style="max-width:420px;margin:0 auto">' +
'<div style="text-align:center"><div class="pin-title" id="pin-step-title">Aktuelle PIN eingeben</div>' +
'<div class="pin-sub" id="pin-step-sub">Zur Bestätigung deiner Identität</div>' +
'<div class="pin-dots" id="pin-dots" style="justify-content:center"></div>' +
'<div class="keypad" id="pin-pad" style="justify-content:center;margin:0 auto"></div></div>' +
'</div>';
/* Die alte PIN wird hier nicht geprüft — das macht der Server beim
Speichern. Die Oberfläche sammelt nur die drei Eingaben ein. */
bauePinPad($("pin-dots"), $("pin-pad"), (alt, reset) => {
reset();
$("pin-step-title").textContent = "Neue PIN festlegen";
$("pin-step-sub").textContent = "4-stelligen Code eingeben";
// Schritt 2: neue PIN
bauePinPad($("pin-dots"), $("pin-pad"), (neu, reset2) => {
reset2();
$("pin-step-title").textContent = "Neue PIN bestätigen";
$("pin-step-sub").textContent = "Code erneut eingeben";
// Schritt 3: bestätigen
bauePinPad($("pin-dots"), $("pin-pad"), (best, reset3) => {
if (best !== neu) { toast("PINs stimmen nicht überein"); reset3(); return; }
nui("pinAendern", { alt: alt, neu: neu });
reset3();
});
});
});
};
/* =====================================================================
FLASH-OVERLAY (Aktion läuft / Bestätigung)
===================================================================== */
function zeigeFlash(html) { const f = $("flash"); f.innerHTML = html; f.classList.add("show"); }
function versteckeFlash() { $("flash").classList.remove("show"); }
/* =====================================================================
AKTIEN-ARTIGES VERLAUFS-CHART (glatte Linie, Flächenverlauf, Hover-Fadenkreuz)
===================================================================== */
function baueVerlaufChart(wrapper, daten, opts) {
const werte = daten.werte, labels = daten.labels, n = werte.length;
const W = Math.max(320, Math.round(wrapper.clientWidth || 600));
const H = opts.hoehe || 260;
const padL = 58, padR = 14, padT = 14, padB = 26;
const min = Math.min(...werte), max = Math.max(...werte), span = (max - min) || 1;
const lo = min - span * 0.14, hi = max + span * 0.14;
const px = i => padL + (W - padL - padR) * (n === 1 ? 0.5 : i / (n - 1));
const py = v => padT + (H - padT - padB) * (1 - (v - lo) / (hi - lo));
const punkte = werte.map((v, i) => ({ x: px(i), y: py(v) }));
// glatte Kurve (Catmull-Rom -> kubische Bézier)
let linie = "M" + punkte[0].x.toFixed(1) + " " + punkte[0].y.toFixed(1);
for (let i = 0; i < punkte.length - 1; i++) {
const p0 = punkte[i - 1] || punkte[i], p1 = punkte[i], p2 = punkte[i + 1], p3 = punkte[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
linie += " C" + c1x.toFixed(1) + " " + c1y.toFixed(1) + " " + c2x.toFixed(1) + " " + c2y.toFixed(1) + " " + p2.x.toFixed(1) + " " + p2.y.toFixed(1);
}
const flaeche = linie + " L" + punkte[n - 1].x.toFixed(1) + " " + (H - padB) + " L" + punkte[0].x.toFixed(1) + " " + (H - padB) + " Z";
// Gitterlinien + Y-Beschriftung (4 Stufen)
let gitter = "", ylabels = "";
for (let s = 0; s <= 3; s++) {
const wert = lo + (hi - lo) * (s / 3);
const y = py(wert);
gitter += '<line x1="' + padL + '" y1="' + y.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + y.toFixed(1) + '" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>';
ylabels += '<text x="' + (padL - 8) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" fill="#6f8880" font-size="11">' + Math.round(wert / 1000) + "k</text>";
}
// X-Beschriftung
let xlabels = "";
labels.forEach((l, i) => { if (l) xlabels += '<text x="' + px(i).toFixed(1) + '" y="' + (H - 8) + '" text-anchor="middle" fill="#6f8880" font-size="11">' + sicher(l) + '</text>'; });
const end = punkte[n - 1];
const uid = "g" + Math.random().toString(36).slice(2, 7);
wrapper.innerHTML =
'<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" style="height:' + H + 'px">' +
'<defs><linearGradient id="' + uid + '" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="rgba(47,211,154,0.34)"/><stop offset="100%" stop-color="rgba(47,211,154,0)"/></linearGradient></defs>' +
gitter + ylabels + xlabels +
'<path d="' + flaeche + '" fill="url(#' + uid + ')"/>' +
'<path d="' + linie + '" fill="none" stroke="#2fd39a" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>' +
'<circle cx="' + end.x.toFixed(1) + '" cy="' + end.y.toFixed(1) + '" r="4.5" fill="#2fd39a" stroke="#0b1a15" stroke-width="2"/>' +
'</svg>' +
'<div class="chart-cross" id="' + uid + '-cross"></div>' +
'<div class="chart-cursor" id="' + uid + '-cursor"></div>' +
'<div class="chart-tip" id="' + uid + '-tip"></div>';
// Hover-Fadenkreuz + Tooltip
const svg = wrapper.querySelector("svg");
const cross = $(uid + "-cross"), cursor = $(uid + "-cursor"), tip = $(uid + "-tip");
svg.addEventListener("mousemove", e => {
const rect = svg.getBoundingClientRect();
const mxView = (e.clientX - rect.left) * (W / rect.width);
let i = Math.round((mxView - padL) / ((W - padL - padR) / (n - 1)));
i = Math.max(0, Math.min(n - 1, i));
const sx = px(i) * (rect.width / W), sy = py(werte[i]) * (rect.height / H);
cross.style.display = "block"; cross.style.left = sx + "px"; cross.style.height = rect.height + "px";
cursor.style.display = "block"; cursor.style.left = sx + "px"; cursor.style.top = sy + "px";
tip.style.display = "block"; tip.style.left = sx + "px"; tip.style.top = sy + "px";
tip.innerHTML = '<div class="v">' + euro(werte[i]) + '</div><div class="d">' + (labels[i] || ("Punkt " + (i + 1))) + '</div>';
});
svg.addEventListener("mouseleave", () => {
cross.style.display = cursor.style.display = tip.style.display = "none";
});
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-bank";
/* Schickt eine Aktion an den Client; der reicht sie an den Server weiter. */
function nui(aktion, daten) {
if (!IST_FIVEM) { toast("(Browser-Ansicht — keine Serververbindung)"); return Promise.resolve(); }
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Übernimmt Kontodaten vom Server (niemals mit PIN) */
function setzeKonto(daten) {
if (!daten) return;
konto.inhaber = daten.inhaber || "—";
konto.iban = daten.iban || "—";
konto.saldo = daten.saldo || 0;
konto.bargeld = daten.bargeld || 0;
konto.transaktionen = daten.transaktionen || [];
aktualisiereSaldo();
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
zeigeView("v-login"); // jede Sitzung beginnt mit der PIN-Eingabe
initLogin();
}
else if (n.aktion === "pinErgebnis") {
if (n.erfolg) {
if (pinZuruecksetzen) pinZuruecksetzen();
setzeKonto(n.konto);
zeigeView("v-app");
starteApp();
} else {
pinFehlgeschlagen();
}
}
else if (n.aktion === "konto") {
setzeKonto(n);
if (n.hinweis) toast(n.hinweis);
}
else if (n.aktion === "erfolg") {
// Abschlussbildschirm nach einer Buchung
zeigeFlash('<div class="big">✅</div><h3>' + sicher(n.titel || "Erledigt") + '</h3>' +
'<p>' + sicher(n.text || "") + '</p><button class="btn" id="flash-ok">Fertig</button>');
$("flash-ok").addEventListener("click", () => { versteckeFlash(); setzeSektion("uebersicht"); });
}
else if (n.aktion === "fehler") {
versteckeFlash();
toast(n.text || "Aktion fehlgeschlagen");
}
else if (n.aktion === "schliessen") {
document.body.style.display = "none";
}
});
/* ESC schließt den Automaten */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
// Der Test-PIN-Hinweis gehört nicht ins Spiel
const hinweis = $("browser-hinweis");
if (hinweis) hinweis.style.display = "none";
} else {
// Eigenständige Browser-Ansicht: Beispieldaten, Test-PIN 1234
konto.inhaber = "Max Weber";
konto.iban = "DE59 4021 5589 0032 118";
konto.saldo = 48320;
konto.bargeld = 1250;
konto.transaktionen = [
{ titel: "Gehalt — LSPD", betrag: 2400, saldo: 48320, zeit: "2026-07-22 14:00", kat: "Einkommen", icon: "💼" },
{ titel: "Barauszahlung Automat", betrag: -85, saldo: 45920, zeit: "2026-07-22 13:41", kat: "Auszahlung", icon: "🏧" },
{ titel: "Überweisung an Lena Berger", betrag: -300, saldo: 46005, zeit: "2026-07-21 19:12", kat: "Transfer", icon: "↗" },
{ titel: "Bareinzahlung Automat", betrag: 5000, saldo: 46305, zeit: "2026-07-19 10:02", kat: "Einzahlung", icon: "💵" },
];
initLogin();
}
</script>
</body>
</html>