- 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>
939 lines
51 KiB
HTML
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>
|