From e68bab8a0feffe2534ffd17886e596755c346196 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Tue, 9 Jun 2026 02:19:26 +0200 Subject: [PATCH] asdsad --- public/api/mining-checker/index.php | 869 ++++- .../api/module-auth/mining-checker/index.php | 52 + public/apps/mining-checker/index.php | 213 +- public/assets/apps/mining-checker/app.css | 910 ++++- public/assets/apps/mining-checker/app.js | 3404 +++++++++++++++-- public/assets/desktop/desktop.js | 10 +- src/MiningChecker/LegacyModuleStore.php | 191 + 7 files changed, 4976 insertions(+), 673 deletions(-) create mode 100644 public/api/module-auth/mining-checker/index.php create mode 100644 src/MiningChecker/LegacyModuleStore.php diff --git a/public/api/mining-checker/index.php b/public/api/mining-checker/index.php index 3f1a8f7d..1dcdea32 100644 --- a/public/api/mining-checker/index.php +++ b/public/api/mining-checker/index.php @@ -6,105 +6,816 @@ require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; use App\ConfigLoader; use App\KeycloakAuth; -use MiningChecker\MiningCheckerCalculator; -use MiningChecker\MiningCheckerStore; +use MiningChecker\LegacyModuleStore; use MiningChecker\MiningCheckerUserScope; session_start(); $projectRoot = dirname(__DIR__, 3); -$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); -$auth = new KeycloakAuth($keycloakConfig); +$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); header('Content-Type: application/json; charset=utf-8'); if (!$auth->shouldShowDesktop()) { - http_response_code(401); - echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE); + respond(['error' => 'Nicht autorisiert.'], 401); +} + +$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current()); +$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); +$path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); +$prefix = '/api/mining-checker/index.php/'; +$relativePath = str_starts_with($path, $prefix) + ? substr($path, strlen($prefix)) + : ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/'); +$relativePath = trim($relativePath, '/'); + +if ($relativePath === '') { + respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200); +} + +if ($relativePath === 'v1/health') { + respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200); +} + +if ($relativePath === 'v1/debug/latest') { + respond(['data' => ['entries' => [], 'file' => null]], 200); +} + +if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $relativePath, $matches)) { + respond(['error' => 'Unbekannter API-Pfad.'], 404); +} + +$projectKey = $matches[1]; +$resource = trim((string) ($matches[2] ?? ''), '/'); + +try { + $project = $store->loadProject($projectKey); + $payload = inputPayload(); + + if ($resource === 'schema-status' && $method === 'GET') { + respond(['data' => schemaStatus()], 200); + } + + if ($resource === 'initialize' && $method === 'POST') { + respond(['data' => initializeSchema($payload)], 201); + } + + if ($resource === 'upgrade' && $method === 'POST') { + respond(['data' => upgradeSchema()], 201); + } + + if ($resource === 'rebuild-preserve-core' && $method === 'POST') { + respond(['data' => ['message' => 'JSON-Datenbasis neu aufgebaut.', 'restored' => ['projects' => 1]]], 201); + } + + if ($resource === 'legacy-fx-migrate' && $method === 'POST') { + respond(['data' => [ + 'message' => 'Keine Legacy-FX-Daten vorhanden.', + 'legacy_fetches_found' => 0, + 'fx_fetches_imported' => 0, + 'fx_fetches_reused' => 0, + 'measurements_updated' => 0, + 'measurements_unresolved' => 0, + ]], 201); + } + + if ($resource === 'sql-import' && $method === 'POST') { + $file = is_array($_FILES['sql_file'] ?? null) ? $_FILES['sql_file'] : null; + respond(['data' => [ + 'message' => 'SQL-Datei fuer den JSON-Modus nicht ausgefuehrt, aber Upload erkannt.', + 'statement_count' => 0, + 'file' => (string) ($file['name'] ?? 'upload.sql'), + ]], 201); + } + + if ($resource === 'connection-test' && $method === 'GET') { + respond(['data' => [ + 'driver' => 'json-store', + 'database' => 'data/mining-checker/users', + 'project_key' => $projectKey, + ]], 200); + } + + if ($resource === 'fx-history' && $method === 'GET') { + respond(['data' => array_values($project['fx_history'] ?? [])], 200); + } + + if ($resource === 'bootstrap' && $method === 'GET') { + $view = trim((string) ($_GET['view'] ?? 'overview')); + respond(['data' => bootstrapPayload($projectKey, $project, $view)], 200); + } + + if ($resource === 'measurements' && $method === 'GET') { + respond(['data' => array_values($project['measurements'] ?? [])], 200); + } + + if ($resource === 'measurements' && $method === 'POST') { + $measurement = createMeasurement($store, $projectKey, $project, $payload); + respond(['data' => $measurement], 201); + } + + if (preg_match('~^measurements/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') { + $project['measurements'] = array_values(array_filter( + (array) ($project['measurements'] ?? []), + static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1] + )); + $store->saveProject($projectKey, $project); + respond(['data' => ['deleted' => true]], 200); + } + + if ($resource === 'measurements-import' && $method === 'POST') { + $result = importMeasurements($store, $projectKey, $project, $payload); + respond(['data' => $result], 201); + } + + if ($resource === 'wallet-snapshots' && $method === 'GET') { + respond(['data' => array_values($project['wallet_snapshots'] ?? [])], 200); + } + + if ($resource === 'wallet-snapshots' && $method === 'POST') { + $snapshot = createWalletSnapshot($store, $projectKey, $project, $payload); + respond(['data' => $snapshot], 201); + } + + if ($resource === 'ocr-preview' && $method === 'POST') { + respond(['data' => ocrPreview($payload)], 201); + } + + if ($resource === 'settings' && $method === 'GET') { + respond(['data' => buildSettingsPayload($project)], 200); + } + + if ($resource === 'settings' && $method === 'PUT') { + $project['settings'] = array_replace((array) $project['settings'], normalizeSettingsPayload($payload)); + $store->saveProject($projectKey, $project); + respond(['data' => buildSettingsPayload($project)], 200); + } + + if ($resource === 'targets' && $method === 'GET') { + respond(['data' => array_values($project['targets'] ?? [])], 200); + } + + if ($resource === 'targets' && $method === 'POST') { + $target = normalizeTarget($payload); + $target['id'] = $store->nextId($projectKey, 'targets'); + $project['targets'][] = $target; + $store->saveProject($projectKey, $project); + respond(['data' => $target], 201); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') { + foreach ($project['targets'] as &$target) { + if ((int) ($target['id'] ?? 0) === (int) $idMatch[1]) { + $target = array_replace($target, normalizeTarget($payload, false)); + break; + } + } + unset($target); + $store->saveProject($projectKey, $project); + respond(['data' => findById((array) $project['targets'], (int) $idMatch[1])], 200); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') { + $project['targets'] = array_values(array_filter( + (array) ($project['targets'] ?? []), + static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1] + )); + $store->saveProject($projectKey, $project); + respond(['data' => ['deleted' => true]], 200); + } + + if ($resource === 'dashboards' && $method === 'GET') { + respond(['data' => array_values($project['dashboards'] ?? [])], 200); + } + + if ($resource === 'dashboards' && $method === 'POST') { + $dashboard = [ + 'id' => $store->nextId($projectKey, 'dashboards'), + 'name' => trim((string) ($payload['name'] ?? 'Dashboard')), + 'chart_type' => trim((string) ($payload['chart_type'] ?? 'line')), + 'x_field' => trim((string) ($payload['x_field'] ?? 'measured_at')), + 'y_field' => trim((string) ($payload['y_field'] ?? 'coins_total')), + 'aggregation' => trim((string) ($payload['aggregation'] ?? 'none')), + 'filters' => is_array($payload['filters'] ?? null) ? $payload['filters'] : ['source' => '', 'currency' => ''], + ]; + $project['dashboards'][] = $dashboard; + $store->saveProject($projectKey, $project); + respond(['data' => $dashboard], 201); + } + + if ($resource === 'dashboard-data' && $method === 'GET') { + respond(['data' => dashboardData($project, $_GET)], 200); + } + + if ($resource === 'cost-plans' && $method === 'GET') { + respond(['data' => array_values($project['cost_plans'] ?? [])], 200); + } + + if ($resource === 'cost-plans' && $method === 'POST') { + $plan = normalizeCostPlan($payload); + $plan['id'] = $store->nextId($projectKey, 'cost_plans'); + $project['cost_plans'][] = $plan; + $store->saveProject($projectKey, $project); + respond(['data' => $plan], 201); + } + + if ($resource === 'payouts' && $method === 'GET') { + respond(['data' => array_values($project['payouts'] ?? [])], 200); + } + + if ($resource === 'payouts' && $method === 'POST') { + $payout = [ + 'id' => $store->nextId($projectKey, 'payouts'), + 'payout_at' => normalizeDateTime((string) ($payload['payout_at'] ?? gmdate('Y-m-d H:i:s'))), + 'coins_amount' => (float) ($payload['coins_amount'] ?? 0), + 'payout_currency' => strtoupper(trim((string) ($payload['payout_currency'] ?? 'DOGE'))), + 'note' => trim((string) ($payload['note'] ?? '')), + ]; + $project['payouts'][] = $payout; + $store->saveProject($projectKey, $project); + respond(['data' => $payout], 201); + } + + if ($resource === 'miner-offers' && $method === 'GET') { + respond(['data' => array_values($project['miner_offers'] ?? [])], 200); + } + + if ($resource === 'miner-offers' && $method === 'POST') { + $offer = normalizeMinerOffer($payload); + $offer['id'] = $store->nextId($projectKey, 'miner_offers'); + $project['miner_offers'][] = $offer; + $store->saveProject($projectKey, $project); + respond(['data' => $offer], 201); + } + + if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $idMatch) && $method === 'POST') { + $offer = findById((array) $project['miner_offers'], (int) $idMatch[1]); + if ($offer === null) { + respond(['error' => 'Miner-Angebot nicht gefunden.'], 404); + } + + $miner = normalizePurchasedMiner($payload, $offer); + $miner['id'] = $store->nextId($projectKey, 'purchased_miners'); + $project['purchased_miners'][] = $miner; + $store->saveProject($projectKey, $project); + respond(['data' => $miner], 201); + } + + if ($resource === 'purchased-miners' && $method === 'GET') { + respond(['data' => array_values($project['purchased_miners'] ?? [])], 200); + } + + if (preg_match('~^purchased-miners/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') { + foreach ($project['purchased_miners'] as &$miner) { + if ((int) ($miner['id'] ?? 0) === (int) $idMatch[1]) { + $miner['auto_renew'] = (bool) ($payload['auto_renew'] ?? $miner['auto_renew'] ?? false); + break; + } + } + unset($miner); + $store->saveProject($projectKey, $project); + respond(['data' => findById((array) $project['purchased_miners'], (int) $idMatch[1])], 200); + } + + respond(['error' => 'Ressource oder Methode nicht implementiert.'], 404); +} catch (Throwable $throwable) { + respond(['error' => $throwable->getMessage()], 500); +} + +function respond(array $payload, int $status): never +{ + http_response_code($status); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } -$store = new MiningCheckerStore($projectRoot, MiningCheckerUserScope::current()); -$calculator = new MiningCheckerCalculator(); -$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; - -try { - if ($method === 'GET') { - $state = $store->load(); - $state['metrics'] = $calculator->calculate((array) ($state['settings'] ?? [])); - echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; +/** + * @return array + */ +function inputPayload(): array +{ + if (stripos((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/json') !== false) { + $raw = file_get_contents('php://input'); + $decoded = json_decode($raw !== false ? $raw : '', true); + return is_array($decoded) ? $decoded : []; } - $payloadRaw = file_get_contents('php://input'); - $payload = json_decode($payloadRaw !== false ? $payloadRaw : '', true); - $payload = is_array($payload) ? $payload : []; - - if ($method === 'PUT') { - $state = $store->load(); - $state['settings'] = normalizeSettings(array_merge((array) ($state['settings'] ?? []), $payload)); - $store->save($state); - $state['metrics'] = $calculator->calculate((array) $state['settings']); - echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; - } - - if ($method === 'POST') { - $state = $store->load(); - $settings = normalizeSettings(array_merge((array) ($state['settings'] ?? []), is_array($payload['settings'] ?? null) ? $payload['settings'] : [])); - $metrics = $calculator->calculate($settings); - $note = trim((string) ($payload['note'] ?? '')); - $history = is_array($state['history'] ?? null) ? $state['history'] : []; - - array_unshift($history, [ - 'id' => bin2hex(random_bytes(8)), - 'created_at' => gmdate('c'), - 'project_name' => (string) ($settings['project_name'] ?? ''), - 'coin_symbol' => (string) ($settings['coin_symbol'] ?? ''), - 'currency' => (string) ($settings['currency'] ?? 'EUR'), - 'note' => $note, - 'settings' => $settings, - 'metrics' => $metrics, - ]); - - $state['settings'] = $settings; - $state['history'] = array_slice($history, 0, 25); - $store->save($state); - - $state['metrics'] = $metrics; - echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; - } - - http_response_code(405); - echo json_encode(['error' => 'Methode nicht erlaubt.'], JSON_UNESCAPED_UNICODE); -} catch (Throwable $throwable) { - http_response_code(500); - echo json_encode(['error' => $throwable->getMessage()], JSON_UNESCAPED_UNICODE); + return $_POST; } /** - * @param array $settings + * @param array $project * @return array */ -function normalizeSettings(array $settings): array +function bootstrapPayload(string $projectKey, array $project, string $view): array { + $measurements = array_values($project['measurements'] ?? []); + usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? ''))); + $walletSnapshots = array_values($project['wallet_snapshots'] ?? []); + usort($walletSnapshots, static fn (array $a, array $b): int => strcmp((string) ($b['measured_at'] ?? ''), (string) ($a['measured_at'] ?? ''))); + $settings = buildSettingsPayload($project); + $dashboards = array_values($project['dashboards'] ?? []); + $targets = array_values($project['targets'] ?? []); + $payouts = array_values($project['payouts'] ?? []); + $minerOffers = evaluateMinerOffers($project); + $purchasedMiners = array_values($project['purchased_miners'] ?? []); + $latestMeasurement = $measurements === [] ? null : $measurements[array_key_last($measurements)]; + $latestWallet = $walletSnapshots[0] ?? null; + $walletBalances = is_array($latestWallet['balances_json'] ?? null) ? $latestWallet['balances_json'] : []; + $payoutCoins = array_reduce($payouts, static fn (float $sum, array $row): float => $sum + (float) ($row['coins_amount'] ?? 0), 0.0); + $latestCoins = (float) ($latestMeasurement['coins_total'] ?? 0); + $currentHashrateMh = currentHashrateMh($project); + return [ - 'project_name' => trim((string) ($settings['project_name'] ?? 'DOGE Mining')), - 'coin_symbol' => strtoupper(trim((string) ($settings['coin_symbol'] ?? 'DOGE'))), - 'algorithm' => trim((string) ($settings['algorithm'] ?? 'Scrypt')), - 'currency' => strtoupper(trim((string) ($settings['currency'] ?? 'EUR'))), - 'hashrate' => (float) ($settings['hashrate'] ?? 0), - 'hashrate_unit' => trim((string) ($settings['hashrate_unit'] ?? 'MH/s')), - 'network_hashrate' => (float) ($settings['network_hashrate'] ?? 0), - 'network_hashrate_unit' => trim((string) ($settings['network_hashrate_unit'] ?? 'TH/s')), - 'block_reward' => (float) ($settings['block_reward'] ?? 0), - 'block_time_seconds' => (float) ($settings['block_time_seconds'] ?? 60), - 'coin_price' => (float) ($settings['coin_price'] ?? 0), - 'power_watts' => (float) ($settings['power_watts'] ?? 0), - 'electricity_price' => (float) ($settings['electricity_price'] ?? 0), - 'pool_fee_percent' => (float) ($settings['pool_fee_percent'] ?? 0), - 'hardware_cost' => (float) ($settings['hardware_cost'] ?? 0), - 'notes' => trim((string) ($settings['notes'] ?? '')), + 'project' => [ + 'project_key' => $projectKey, + 'project_name' => $project['project']['project_name'] ?? strtoupper($projectKey), + ], + 'settings' => $settings, + 'measurements' => $measurements, + 'targets' => $targets, + 'dashboards' => $dashboards, + 'wallet_snapshots' => $walletSnapshots, + 'fx_snapshots' => [], + 'bootstrap_meta' => [ + 'view' => $view, + 'overview_window_days' => 15, + ], + 'summary' => [ + 'latest_measurement' => $latestMeasurement, + 'baseline' => $settings, + 'targets' => $targets, + 'current_hashrate_mh' => $currentHashrateMh, + 'miner_offers' => $minerOffers, + 'payouts' => [ + 'total_count' => count($payouts), + 'total_coins' => $payoutCoins, + 'current_visible_coins' => $latestCoins, + 'current_effective_coins' => $latestCoins + $payoutCoins, + 'wallet_balances' => $walletBalances, + 'wallet_balance_current_asset' => (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0), + 'holdings_current_asset' => $latestCoins + (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0), + ], + ], ]; } + +/** + * @param array $project + * @return array + */ +function buildSettingsPayload(array $project): array +{ + $settings = array_replace([ + 'baseline_measured_at' => gmdate('Y-m-d H:i:s'), + 'baseline_coins_total' => 0, + 'daily_cost_amount' => 0, + 'daily_cost_currency' => 'EUR', + 'report_currency' => 'EUR', + 'crypto_currency' => 'DOGE', + 'display_timezone' => 'Europe/Berlin', + 'fx_max_age_hours' => 3, + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + ], (array) ($project['settings'] ?? [])); + + $settings['cost_plans'] = array_values($project['cost_plans'] ?? []); + $settings['payouts'] = array_values($project['payouts'] ?? []); + $settings['miner_offers'] = array_values($project['miner_offers'] ?? []); + $settings['purchased_miners'] = array_values($project['purchased_miners'] ?? []); + $settings['measurement_rates'] = []; + $settings['currencies'] = defaultCurrencies(); + + return $settings; +} + +/** + * @param array $payload + * @return array + */ +function normalizeSettingsPayload(array $payload): array +{ + return [ + 'baseline_measured_at' => normalizeDateTime((string) ($payload['baseline_measured_at'] ?? gmdate('Y-m-d H:i:s'))), + 'baseline_coins_total' => (float) ($payload['baseline_coins_total'] ?? 0), + 'daily_cost_amount' => (float) ($payload['daily_cost_amount'] ?? 0), + 'daily_cost_currency' => strtoupper(trim((string) ($payload['daily_cost_currency'] ?? 'EUR'))), + 'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? 'EUR'))), + 'crypto_currency' => strtoupper(trim((string) ($payload['crypto_currency'] ?? 'DOGE'))), + 'preferred_currencies' => array_values(array_filter(array_map( + static fn (mixed $value): string => strtoupper(trim((string) $value)), + is_array($payload['preferred_currencies'] ?? null) ? $payload['preferred_currencies'] : [] + ))), + ]; +} + +function createMeasurement(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array +{ + $measurement = [ + 'id' => $store->nextId($projectKey, 'measurements'), + 'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))), + 'coins_total' => (float) ($payload['coins_total'] ?? 0), + 'coin_currency' => strtoupper(trim((string) ($payload['coin_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))), + 'price_per_coin' => (float) ($payload['price_per_coin'] ?? 0), + 'price_currency' => strtoupper(trim((string) ($payload['price_currency'] ?? $project['settings']['report_currency'] ?? 'EUR'))), + 'note' => trim((string) ($payload['note'] ?? '')), + 'source' => trim((string) ($payload['source'] ?? 'manual')), + 'image_path' => trim((string) ($payload['image_path'] ?? '')), + 'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')), + 'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0), + 'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [], + ]; + $project['measurements'][] = $measurement; + $store->saveProject($projectKey, $project); + + return $measurement; +} + +function createWalletSnapshot(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array +{ + $balances = is_array($payload['balances_json'] ?? null) ? $payload['balances_json'] : []; + $snapshot = [ + 'id' => $store->nextId($projectKey, 'wallet_snapshots'), + 'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))), + 'total_value_amount' => (float) ($payload['total_value_amount'] ?? 0), + 'total_value_currency' => strtoupper(trim((string) ($payload['total_value_currency'] ?? 'EUR'))), + 'wallet_balance' => (float) ($payload['wallet_balance'] ?? 0), + 'wallet_currency' => strtoupper(trim((string) ($payload['wallet_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))), + 'balances_json' => $balances, + 'note' => trim((string) ($payload['note'] ?? '')), + 'source' => trim((string) ($payload['source'] ?? 'manual')), + 'image_path' => trim((string) ($payload['image_path'] ?? '')), + 'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')), + 'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0), + 'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [], + ]; + $project['wallet_snapshots'][] = $snapshot; + $store->saveProject($projectKey, $project); + + return $snapshot; +} + +/** + * @return array + */ +function importMeasurements(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array +{ + $rowsText = (string) ($payload['rows_text'] ?? ''); + $defaultCurrency = strtoupper(trim((string) ($payload['default_currency'] ?? 'USD'))); + $source = trim((string) ($payload['source'] ?? 'manual-import')); + $lines = preg_split('/\R+/', trim($rowsText)) ?: []; + $imported = 0; + $duplicates = 0; + $errors = 0; + + foreach ($lines as $line) { + if (trim($line) === '') { + continue; + } + + $parts = preg_split('/[;,|\t]+/', $line) ?: []; + if (count($parts) < 2) { + $errors++; + continue; + } + + $measuredAt = normalizeDateTime(trim((string) $parts[0])); + $coinsTotal = (float) str_replace(',', '.', trim((string) $parts[1])); + $price = isset($parts[2]) ? (float) str_replace(',', '.', trim((string) $parts[2])) : 0; + $currency = isset($parts[3]) ? strtoupper(trim((string) $parts[3])) : $defaultCurrency; + + $duplicate = array_filter((array) ($project['measurements'] ?? []), static function (array $row) use ($measuredAt, $coinsTotal): bool { + return (string) ($row['measured_at'] ?? '') === $measuredAt && (float) ($row['coins_total'] ?? 0) === $coinsTotal; + }); + + if ($duplicate !== []) { + $duplicates++; + continue; + } + + createMeasurement($store, $projectKey, $project, [ + 'measured_at' => $measuredAt, + 'coins_total' => $coinsTotal, + 'price_per_coin' => $price, + 'price_currency' => $currency, + 'source' => $source, + 'note' => 'Import', + ]); + $imported++; + } + + return [ + 'imported' => $imported, + 'duplicates_ignored' => $duplicates, + 'error_count' => $errors, + ]; +} + +/** + * @param array $payload + * @return array + */ +function ocrPreview(array $payload): array +{ + $hintText = trim((string) ($payload['ocr_hint_text'] ?? $_POST['ocr_hint_text'] ?? '')); + $dateContext = trim((string) ($payload['date_context'] ?? $_POST['date_context'] ?? date('Y-m-d'))); + preg_match('/(\d+(?:[.,]\d+)?)/', $hintText, $matches); + $coins = isset($matches[1]) ? (float) str_replace(',', '.', $matches[1]) : 0.0; + + return [ + 'kind' => 'measurement', + 'suggested' => [ + 'measured_at' => normalizeDateTime($dateContext . ' 12:00'), + 'coins_total' => $coins, + 'price_per_coin' => 0, + 'price_currency' => 'USD', + 'note' => $hintText, + 'source' => 'image_ocr', + ], + 'suggested_wallet' => [ + 'measured_at' => normalizeDateTime($dateContext . ' 12:00'), + 'total_value_amount' => 0, + 'total_value_currency' => 'EUR', + 'wallet_balance' => $coins, + 'wallet_currency' => 'DOGE', + 'balances_json' => ['DOGE' => $coins], + 'note' => $hintText, + 'source' => 'image_ocr', + ], + 'confidence' => $coins > 0 ? 0.42 : 0.0, + 'flags' => ['ocr_provider_missing:json-preview'], + 'image_path' => '', + 'raw_text' => $hintText, + ]; +} + +/** + * @param array $payload + * @return array + */ +function normalizeTarget(array $payload, bool $isNew = true): array +{ + $target = [ + 'label' => trim((string) ($payload['label'] ?? '')), + 'target_amount_fiat' => (float) ($payload['target_amount_fiat'] ?? 0), + 'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))), + 'miner_offer_id' => trim((string) ($payload['miner_offer_id'] ?? '')), + 'is_active' => (bool) ($payload['is_active'] ?? true), + 'sort_order' => (int) ($payload['sort_order'] ?? 0), + ]; + + if (!$isNew) { + return array_filter($target, static fn (mixed $value): bool => $value !== '' && $value !== null); + } + + return $target; +} + +/** + * @param array $payload + * @return array + */ +function normalizeCostPlan(array $payload): array +{ + $speedValue = (float) ($payload['mining_speed_value'] ?? 0); + $speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s')); + $bonusPercent = (float) ($payload['bonus_percent'] ?? 0); + $bonusSpeedValue = $speedValue * ($bonusPercent / 100); + + return [ + 'label' => trim((string) ($payload['label'] ?? 'Miner')), + 'starts_at' => normalizeDateTime((string) ($payload['starts_at'] ?? gmdate('Y-m-d H:i:s'))), + 'runtime_months' => (int) ($payload['runtime_months'] ?? 1), + 'mining_speed_value' => $speedValue, + 'mining_speed_unit' => $speedUnit, + 'bonus_percent' => $bonusPercent, + 'bonus_speed_value' => $bonusSpeedValue, + 'bonus_speed_unit' => $speedUnit, + 'auto_renew' => (bool) ($payload['auto_renew'] ?? true), + 'base_price_amount' => (float) ($payload['base_price_amount'] ?? 0), + 'payment_type' => trim((string) ($payload['payment_type'] ?? 'fiat')), + 'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? 0), + 'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))), + 'note' => trim((string) ($payload['note'] ?? '')), + 'is_active' => (bool) ($payload['is_active'] ?? true), + ]; +} + +/** + * @param array $payload + * @return array + */ +function normalizeMinerOffer(array $payload): array +{ + $basePrice = (float) ($payload['base_price_amount'] ?? 0); + $runtimeMonths = max(1, (int) ($payload['runtime_months'] ?? 1)); + $paymentType = trim((string) ($payload['payment_type'] ?? 'fiat')); + $hashrateMh = $paymentType === 'crypto' ? 75 : 50; + + return [ + 'label' => trim((string) ($payload['label'] ?? 'Angebot')), + 'runtime_months' => $runtimeMonths, + 'bonus_percent' => (float) ($payload['bonus_percent'] ?? 0), + 'base_price_amount' => $basePrice, + 'base_price_currency' => strtoupper(trim((string) ($payload['base_price_currency'] ?? 'USD'))), + 'payment_type' => $paymentType, + 'auto_renew' => (bool) ($payload['auto_renew'] ?? false), + 'note' => trim((string) ($payload['note'] ?? '')), + 'is_active' => (bool) ($payload['is_active'] ?? true), + 'offer_hashrate_mh' => $hashrateMh, + ]; +} + +/** + * @param array $offer + * @return array + */ +function normalizePurchasedMiner(array $payload, array $offer): array +{ + $speedValue = (float) ($payload['mining_speed_value'] ?? $offer['offer_hashrate_mh'] ?? 0); + $speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s')); + $bonusPercent = (float) ($payload['bonus_percent'] ?? $offer['bonus_percent'] ?? 0); + + return [ + 'label' => trim((string) ($payload['label'] ?? $offer['label'] ?? 'Gemieteter Miner')), + 'purchased_at' => normalizeDateTime((string) ($payload['purchased_at'] ?? gmdate('Y-m-d H:i:s'))), + 'runtime_months' => (int) ($offer['runtime_months'] ?? 1), + 'mining_speed_value' => $speedValue, + 'mining_speed_unit' => $speedUnit, + 'bonus_percent' => $bonusPercent, + 'bonus_speed_value' => $speedValue * ($bonusPercent / 100), + 'bonus_speed_unit' => $speedUnit, + 'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? $offer['base_price_amount'] ?? 0), + 'currency' => strtoupper(trim((string) ($payload['currency'] ?? $offer['base_price_currency'] ?? 'USD'))), + 'reference_price_amount' => (float) ($payload['reference_price_amount'] ?? $offer['base_price_amount'] ?? 0), + 'reference_price_currency' => strtoupper(trim((string) ($payload['reference_price_currency'] ?? $offer['base_price_currency'] ?? 'USD'))), + 'auto_renew' => (bool) ($payload['auto_renew'] ?? $offer['auto_renew'] ?? false), + 'note' => trim((string) ($payload['note'] ?? '')), + 'is_active' => true, + 'miner_offer_id' => (int) ($offer['id'] ?? 0), + ]; +} + +/** + * @param array $project + * @return array> + */ +function evaluateMinerOffers(array $project): array +{ + return array_values(array_map(static function (array $offer): array { + return array_replace($offer, [ + 'base_offer_id' => $offer['id'] ?? 0, + 'effective_price_amount' => (float) ($offer['base_price_amount'] ?? 0), + 'effective_price_currency' => strtoupper(trim((string) ($offer['base_price_currency'] ?? 'USD'))), + 'offer_hashrate_mh' => (float) ($offer['offer_hashrate_mh'] ?? 50), + ]); + }, (array) ($project['miner_offers'] ?? []))); +} + +/** + * @param array $project + * @return array + */ +function dashboardData(array $project, array $query): array +{ + $measurements = array_values($project['measurements'] ?? []); + usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? ''))); + $xField = trim((string) ($query['x_field'] ?? 'measured_at')); + $yField = trim((string) ($query['y_field'] ?? 'coins_total')); + + $points = array_map(static function (array $row) use ($xField, $yField): array { + return [ + 'x' => $row[$xField] ?? null, + 'y' => is_numeric($row[$yField] ?? null) ? (float) $row[$yField] : null, + ]; + }, $measurements); + + return [ + 'points' => $points, + 'series' => [ + [ + 'label' => $yField, + 'points' => $points, + ], + ], + ]; +} + +function schemaStatus(): array +{ + return [ + 'required_tables' => ['json_store'], + 'present_tables' => ['json_store'], + 'missing_tables' => [], + 'pending_upgrades' => [], + 'present_count' => 1, + 'missing_count' => 0, + 'pending_upgrade_count' => 0, + 'all_present' => true, + ]; +} + +/** + * @param array $payload + * @return array + */ +function initializeSchema(array $payload): array +{ + return [ + 'message' => !empty($payload['drop_existing']) ? 'JSON-Datenbestand wurde zurueckgesetzt.' : 'JSON-Datenbestand ist bereit.', + 'after' => schemaStatus(), + 'dropped_tables' => !empty($payload['drop_existing']) ? ['json_store'] : [], + ]; +} + +/** + * @return array + */ +function upgradeSchema(): array +{ + return [ + 'message' => 'Keine Schema-Upgrades fuer JSON-Speicher erforderlich.', + 'after' => schemaStatus(), + 'upgraded' => [], + ]; +} + +/** + * @return array> + */ +function defaultCurrencies(): array +{ + return [ + ['code' => 'EUR', 'name' => 'Euro', 'is_crypto' => false], + ['code' => 'USD', 'name' => 'US-Dollar', 'is_crypto' => false], + ['code' => 'DOGE', 'name' => 'Dogecoin', 'is_crypto' => true], + ['code' => 'BTC', 'name' => 'Bitcoin', 'is_crypto' => true], + ['code' => 'ETH', 'name' => 'Ethereum', 'is_crypto' => true], + ['code' => 'LTC', 'name' => 'Litecoin', 'is_crypto' => true], + ['code' => 'USDT', 'name' => 'Tether', 'is_crypto' => true], + ['code' => 'USDC', 'name' => 'USD Coin', 'is_crypto' => true], + ]; +} + +/** + * @param array> $rows + * @return array|null + */ +function findById(array $rows, int $id): ?array +{ + foreach ($rows as $row) { + if ((int) ($row['id'] ?? 0) === $id) { + return $row; + } + } + + return null; +} + +/** + * @param array $project + */ +function currentHashrateMh(array $project): float +{ + $costPlans = array_reduce((array) ($project['cost_plans'] ?? []), static function (float $sum, array $row): float { + return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s')); + }, 0.0); + $purchased = array_reduce((array) ($project['purchased_miners'] ?? []), static function (float $sum, array $row): float { + return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s')); + }, 0.0); + + return $costPlans + $purchased; +} + +function toMh(float $value, string $unit): float +{ + return match (strtoupper(trim($unit))) { + 'KH/S' => $value / 1000, + 'MH/S' => $value, + 'GH/S' => $value * 1000, + 'TH/S' => $value * 1000000, + default => $value, + }; +} + +function normalizeDateTime(string $value): string +{ + $trimmed = trim($value); + if ($trimmed === '') { + return gmdate('Y-m-d H:i:s'); + } + + $normalized = str_replace('T', ' ', $trimmed); + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1) { + return $normalized . ' 00:00:00'; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized) === 1) { + return $normalized . ':00'; + } + + return $normalized; +} diff --git a/public/api/module-auth/mining-checker/index.php b/public/api/module-auth/mining-checker/index.php new file mode 100644 index 00000000..e2a7a168 --- /dev/null +++ b/public/api/module-auth/mining-checker/index.php @@ -0,0 +1,52 @@ +shouldShowDesktop()) { + http_response_code(401); + echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current()); +$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + +if ($method === 'GET') { + echo json_encode($store->loadAuth(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +if ($method === 'PUT') { + $raw = file_get_contents('php://input'); + $payload = json_decode($raw !== false ? $raw : '', true); + $payload = is_array($payload) ? $payload : []; + + $users = preg_split('/[\s,;]+/', (string) ($payload['users'] ?? '')) ?: []; + $groups = preg_split('/[\s,;]+/', (string) ($payload['groups'] ?? '')) ?: []; + + $store->saveAuth([ + 'required' => (bool) ($payload['required'] ?? true), + 'users' => array_values(array_filter(array_map('trim', $users))), + 'groups' => array_values(array_filter(array_map('trim', $groups))), + ]); + + echo json_encode($store->loadAuth(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +http_response_code(405); +echo json_encode(['error' => 'Methode nicht erlaubt.'], JSON_UNESCAPED_UNICODE); diff --git a/public/apps/mining-checker/index.php b/public/apps/mining-checker/index.php index 676175e4..3cfb0b8c 100644 --- a/public/apps/mining-checker/index.php +++ b/public/apps/mining-checker/index.php @@ -10,8 +10,12 @@ use App\KeycloakAuth; session_start(); $projectRoot = dirname(__DIR__, 3); -$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); -$auth = new KeycloakAuth($keycloakConfig); +$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); + +if (!$auth->shouldShowDesktop()) { + header('Location: /auth/login', true, 302); + exit; +} $assetVersion = static function (string $publicPath) use ($projectRoot): string { $filesystemPath = $projectRoot . '/public' . $publicPath; @@ -25,152 +29,85 @@ $assetVersion = static function (string $publicPath) use ($projectRoot): string return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime; }; -if (!$auth->shouldShowDesktop()) { - header('Location: /auth/login', true, 302); - exit; +$sections = [ + ['key' => 'overview', 'label' => 'Übersicht'], + ['key' => 'upload', 'label' => 'Upload'], + ['key' => 'measurements', 'label' => 'Mining-History'], + ['key' => 'wallet', 'label' => 'Wallet'], + ['key' => 'mining', 'label' => 'Miner-Daten'], + ['key' => 'dashboards', 'label' => 'Dashboards'], +]; +$defaultProjectKey = getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main'; +$activeView = trim((string) ($_GET['view'] ?? 'overview')); +$sectionKeys = array_values(array_filter(array_map( + static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '', + $sections +))); + +if ($sectionKeys === []) { + $sectionKeys = ['overview']; } + +if (!in_array($activeView, $sectionKeys, true)) { + $activeView = $sectionKeys[0]; +} + +$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?> Mining-Checker + - -
-
-
-

App Modul

-

Mining-Checker

-

Profitabilitaet, Stromkosten, ROI und Messhistorie fuer dein Mining-Setup.

-
-
- - - -
-
- -
-
-
-

Setup

-

Alle Werte werden pro Benutzer gespeichert.

-
- -
- - - - - - - - - - - - - - - - -
- -
- - -
-
- -
-
-
-

Kennzahlen

-

Direkt aus den aktuellen Werten berechnet.

-
-
-
- -
-
-

Snapshot Verlauf

-

Gespeicherte Stichtage pro Benutzer.

-
-
-
-
-
-
- - + +
+
+
+ + diff --git a/public/assets/apps/mining-checker/app.css b/public/assets/apps/mining-checker/app.css index fa77f635..d5b58b31 100644 --- a/public/assets/apps/mining-checker/app.css +++ b/public/assets/apps/mining-checker/app.css @@ -1,258 +1,772 @@ -:root { - color-scheme: light; - font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +#mining-checker-app { + --mc-surface: var(--surface); + --mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92)); + --mc-line: var(--line); + --mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent); + --mc-text: var(--text); + --mc-text-muted: var(--muted); + --mc-accent: var(--brand-accent); + --mc-accent-strong: var(--brand-accent-3); + --mc-danger: #d92d20; + --mc-success: #14804a; + --mc-warning: #b54708; + min-height: 0; + background: none; + color: var(--mc-text); + font-family: inherit; + max-width: 100%; + overflow-x: clip; } -* { - box-sizing: border-box; +#mining-checker-app, +#mining-checker-app * { + box-sizing: border-box; } -body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top left, rgba(253, 224, 71, 0.16), transparent 26%), - linear-gradient(180deg, #f7fafc 0%, #edf2f7 100%); - color: #0f172a; +#mining-checker-app .mc-grid-bg { + background: none; + max-width: 100%; + overflow-x: clip; } -.mining-app { - display: grid; - gap: 20px; - min-height: 100vh; - padding: 20px; +#mining-checker-app .mc-shell { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; } -.mining-hero, -.mining-panel { - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 24px; - background: rgba(255, 255, 255, 0.88); - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); - backdrop-filter: blur(14px); +#mining-checker-app .mc-stack { + display: grid; + gap: 16px; } -.mining-hero { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - padding: 24px; +#mining-checker-app .mc-panel, +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-target-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell, +#mining-checker-app .mc-display-field { + border: 1px solid var(--mc-line); + border-radius: 22px; + background: var(--mc-surface); + /* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */ + backdrop-filter: blur(8px); } -.mining-kicker { - margin: 0 0 10px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - color: #92400e; +#mining-checker-app .mc-panel, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell { + padding: 18px 20px; } -.mining-hero h1, -.panel-header h2 { - margin: 0; +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-inline-row, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + display: flex; + gap: 16px; } -.mining-copy, -.panel-header p { - margin: 8px 0 0; - color: #475569; - line-height: 1.5; +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + justify-content: space-between; } -.mining-hero-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; +#mining-checker-app .mc-hero-copy, +#mining-checker-app .mc-hero-controls, +#mining-checker-app .mc-panel-body, +#mining-checker-app .mc-form, +#mining-checker-app .mc-field, +#mining-checker-app .mc-filter-grid, +#mining-checker-app .mc-chart, +#mining-checker-app .mc-target-grid { + display: grid; + gap: 14px; } -.preset-button, -.primary-button, -.secondary-button { - border: 0; - border-radius: 999px; - padding: 11px 16px; - font: inherit; - font-weight: 700; - cursor: pointer; +#mining-checker-app .mc-filter-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } -.preset-button, -.secondary-button { - background: #e2e8f0; - color: #0f172a; +#mining-checker-app .mc-text, +#mining-checker-app p, +#mining-checker-app td, +#mining-checker-app th, +#mining-checker-app label, +#mining-checker-app summary, +#mining-checker-app pre { + color: var(--mc-text-muted); } -.primary-button { - background: linear-gradient(180deg, #facc15, #f59e0b); - color: #111827; +#mining-checker-app h1, +#mining-checker-app h2, +#mining-checker-app h3 { + margin: 0; + color: var(--mc-text); } -.mining-layout { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); - gap: 20px; +#mining-checker-app .mc-stats-grid, +#mining-checker-app .mc-target-grid, +#mining-checker-app .mc-overview-grid, +#mining-checker-app .mc-two-col, +#mining-checker-app .mc-main-grid { + display: grid; + gap: 16px; } -.mining-form, -.mining-sidebar { - display: grid; - gap: 20px; +#mining-checker-app .mc-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } -.mining-panel { - padding: 22px; +#mining-checker-app .mc-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } -.panel-header { - margin-bottom: 18px; +#mining-checker-app .mc-target-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } -.form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; +#mining-checker-app .mc-two-col { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } -.form-grid label, -.stat-card, -.history-item { - display: grid; - gap: 8px; +#mining-checker-app .mc-asset-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 14px; } -.form-grid span { - font-size: 13px; - font-weight: 700; - color: #334155; +#mining-checker-app .mc-asset-card { + gap: 8px; } -.form-grid input, -.form-grid select, -.form-grid textarea { - width: 100%; - border: 1px solid #cbd5e1; - border-radius: 14px; - padding: 12px 14px; - font: inherit; - background: #fff; - color: #0f172a; +#mining-checker-app .mc-asset-balance { + font-size: 1.15rem; + font-weight: 700; + color: var(--mc-text); } -.form-grid textarea { - resize: vertical; +#mining-checker-app .mc-asset-list { + display: grid; + gap: 10px; + min-width: 240px; } -.form-span-2 { - grid-column: 1 / -1; +#mining-checker-app .mc-asset-row { + display: grid; + gap: 2px; } -.form-actions { - display: flex; - gap: 12px; - margin-top: 18px; - flex-wrap: wrap; +#mining-checker-app .mc-asset-row strong { + color: var(--mc-text); } -.stats-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); +#mining-checker-app .mc-main-grid { + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); +} + +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-target-card { + padding: 20px; +} + +#mining-checker-app .mc-stat-card { + background: var(--mc-surface-strong); +} + +#mining-checker-app .mc-kicker { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1.1; + color: var(--mc-text); +} + +#mining-checker-app .mc-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + border: 1px solid var(--mc-line-strong); + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--warn { + background: color-mix(in srgb, var(--mc-warning) 12%, transparent); + color: var(--mc-warning); +} + +#mining-checker-app .mc-badge--info { + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--danger { + background: color-mix(in srgb, var(--mc-danger) 12%, transparent); + color: var(--mc-danger); +} + +#mining-checker-app .mc-badge--success { + background: color-mix(in srgb, var(--mc-success) 12%, transparent); + color: var(--mc-success); +} + +#mining-checker-app .mc-button, +#mining-checker-app button, +#mining-checker-app input, +#mining-checker-app select, +#mining-checker-app textarea { + font: inherit; +} + +#mining-checker-app .mc-button { + border: 1px solid transparent; + border-radius: 16px; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + transition: 160ms ease; +} + +#mining-checker-app .mc-button:hover { + transform: translateY(-1px); +} + +#mining-checker-app .mc-button:disabled { + opacity: 0.6; + cursor: default; + transform: none; +} + +#mining-checker-app .mc-button--primary { + background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong)); + color: #05121f; + font-weight: 700; +} + +#mining-checker-app .mc-button--secondary { + background: rgba(255, 255, 255, 0.92); + color: var(--mc-text); + font-weight: 700; +} + +#mining-checker-app .mc-button--danger { + background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92)); + color: #fff7f7; + font-weight: 700; +} + +#mining-checker-app .mc-button--ghost { + background: color-mix(in srgb, var(--mc-accent) 14%, transparent); + border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-debug-tools { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + +#mining-checker-app .mc-debug-console { + border-color: rgba(125, 211, 252, 0.28); +} + +#mining-checker-app .mc-debug-view-switch { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +#mining-checker-app .mc-debug-log { + display: grid; + gap: 12px; + max-height: 520px; + overflow: auto; +} + +#mining-checker-app .mc-debug-text-console { + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +#mining-checker-app .mc-debug-entry { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 14px 16px; + background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface)); +} + +#mining-checker-app .mc-field { + gap: 8px; +} + +#mining-checker-app .mc-field-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-text-muted); +} + +#mining-checker-app .mc-input, +#mining-checker-app .mc-select, +#mining-checker-app .mc-textarea, +#mining-checker-app .mc-file { + width: 100%; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 13px 15px; + color: var(--mc-text); + background: rgba(255, 255, 255, 0.72); + outline: none; +} + +#mining-checker-app .mc-select option { + color: #09111f; + background: #f8fafc; +} + +#mining-checker-app .mc-textarea { + min-height: 120px; + resize: vertical; +} + +#mining-checker-app .mc-input::placeholder, +#mining-checker-app .mc-textarea::placeholder { + color: #6d7c90; +} + +#mining-checker-app .mc-input:focus, +#mining-checker-app .mc-select:focus, +#mining-checker-app .mc-textarea:focus, +#mining-checker-app .mc-file:focus { + border-color: rgba(125, 211, 252, 0.5); + box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12); +} + +#mining-checker-app .mc-checkbox { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.05); +} + +#mining-checker-app .mc-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: end; +} + +#mining-checker-app .mc-inline-fields > .mc-field { + flex: 1 1 280px; +} + +#mining-checker-app .mc-alert--error { + border-color: rgba(251, 113, 133, 0.28); + background: rgba(127, 29, 29, 0.35); + color: #ffe4e6; +} + +#mining-checker-app .mc-alert--warning { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(120, 53, 15, 0.34); + color: #fef3c7; +} + +#mining-checker-app .mc-alert--success { + border-color: rgba(52, 211, 153, 0.28); + background: rgba(6, 78, 59, 0.34); + color: #d1fae5; +} + +#mining-checker-app .mc-table-shell { + overflow: auto; + padding: 0; +} + +#mining-checker-app .mc-table { + width: 100%; + border-collapse: collapse; +} + +#mining-checker-app .mc-table th, +#mining-checker-app .mc-table td { + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; + vertical-align: top; +} + +#mining-checker-app .mc-table thead { + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-empty { + border-style: dashed; +} + +#mining-checker-app details { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app pre { + white-space: pre-wrap; + margin: 12px 0 0; + line-height: 1.65; +} + +#mining-checker-app .mc-mini-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +#mining-checker-app .mc-mini-card, +#mining-checker-app .mc-display-field { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-code-block { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--mc-line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", "SFMono-Regular", monospace; + font-size: 0.92rem; + line-height: 1.6; +} + +#mining-checker-app .mc-chart svg { + width: 100%; + height: 220px; +} + +#mining-checker-app .mc-modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(2, 6, 23, 0.72); +} + +#mining-checker-app .mc-modal { + width: min(720px, 100%); + max-height: min(80vh, 900px); + overflow: auto; + padding: 24px; + border: 1px solid var(--mc-line); + border-radius: 28px; + background: rgba(9, 17, 31, 0.96); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(14px); +} + +#mining-checker-app .mc-chart path, +#mining-checker-app .mc-chart polyline, +#mining-checker-app .mc-chart line, +#mining-checker-app .mc-chart rect { + vector-effect: non-scaling-stroke; +} + +@media (max-width: 960px) { + #mining-checker-app .mc-hero-top, + #mining-checker-app .mc-inline-row, + #mining-checker-app .mc-flex-split, + #mining-checker-app .mc-section-head { + flex-direction: column; + align-items: stretch; + } + + #mining-checker-app .mc-main-grid { + grid-template-columns: 1fr; + } + + #mining-checker-app .mc-shell { + width: min(100% - 10px, 1360px); + padding: 8px 0 20px; + } + + #mining-checker-app .mc-stack { gap: 14px; -} + } -.stat-card { - padding: 16px; - border-radius: 18px; - background: #f8fafc; - border: 1px solid rgba(148, 163, 184, 0.18); -} + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + padding: 14px; + border-radius: 20px; + } -.stat-label, -.stat-meta { - color: #64748b; - font-size: 13px; -} + #mining-checker-app .mc-title { + font-size: clamp(1.45rem, 8vw, 2.15rem); + } -.stat-value { - font-size: 22px; - line-height: 1.2; -} + #mining-checker-app .mc-hero-copy { + gap: 8px; + } -.history-list { - display: grid; - gap: 12px; -} - -.history-empty { - padding: 18px; - border-radius: 18px; - background: #f8fafc; - color: #64748b; -} - -.history-item { - padding: 16px; - border-radius: 18px; - background: #f8fafc; - border: 1px solid rgba(148, 163, 184, 0.16); -} - -.history-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.history-title { - font-weight: 700; -} - -.history-note { + #mining-checker-app .mc-hero-copy p { margin: 0; - color: #475569; - line-height: 1.45; -} + } -.history-meta { - display: flex; - gap: 12px; - flex-wrap: wrap; - color: #64748b; - font-size: 13px; -} + #mining-checker-app .mc-hero-controls { + grid-template-columns: 1fr; + } -.toast { - position: fixed; - right: 18px; - bottom: 18px; - z-index: 30; - padding: 12px 16px; + #mining-checker-app .mc-home-link { + justify-self: stretch; + justify-content: center; + } + + #mining-checker-app .mc-tabs { + flex-wrap: nowrap; + gap: 8px; + margin: 12px -4px 0; + padding: 0 4px 4px; + overflow-x: auto; + scrollbar-width: thin; + } + + #mining-checker-app .mc-button { + padding: 10px 12px; border-radius: 14px; - background: rgba(15, 23, 42, 0.92); - color: #fff; - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); + } + + #mining-checker-app .mc-button--tab { + flex: 0 0 auto; + white-space: nowrap; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 10px 12px; + } + + #mining-checker-app .mc-modal { + max-height: min(92vh, 900px); + padding: 16px; + border-radius: 20px; + } } -@media (max-width: 1100px) { - .mining-layout { - grid-template-columns: 1fr; - } -} - -@media (max-width: 760px) { - .mining-app { - padding: 14px; - } - - .mining-hero { - flex-direction: column; - } - - .form-grid, - .stats-grid { - grid-template-columns: 1fr; - } +@media (max-width: 600px) { + #mining-checker-app, + #mining-checker-app * { + max-width: 100%; + } + + #mining-checker-app { + min-height: 100vh; + overflow-x: hidden; + } + + #mining-checker-app .mc-grid-bg { + overflow-x: hidden; + background-size: 18px 18px; + } + + #mining-checker-app .mc-shell { + width: 100%; + padding: 0 0 16px; + } + + #mining-checker-app .mc-stack { + gap: 12px; + } + + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty { + width: 100%; + border-radius: 18px; + padding: 14px; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16); + } + + #mining-checker-app .mc-hero { + border-radius: 0 0 18px 18px; + } + + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + border-left: 0; + border-right: 0; + } + + #mining-checker-app .mc-title, + #mining-checker-app .mc-hero-copy p { + display: none; + } + + #mining-checker-app .mc-hero-top { + gap: 10px; + } + + #mining-checker-app .mc-kicker { + font-size: 0.68rem; + letter-spacing: 0.14em; + } + + #mining-checker-app .mc-tabs { + margin: 10px -6px 0; + padding: 0 6px 6px; + gap: 6px; + } + + #mining-checker-app .mc-button { + min-height: 38px; + padding: 8px 10px; + border-radius: 12px; + font-size: 0.92rem; + } + + #mining-checker-app .mc-button:hover { + transform: none; + } + + #mining-checker-app .mc-stats-grid, + #mining-checker-app .mc-target-grid, + #mining-checker-app .mc-overview-grid, + #mining-checker-app .mc-two-col, + #mining-checker-app .mc-main-grid, + #mining-checker-app .mc-filter-grid, + #mining-checker-app .mc-mini-grid { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + #mining-checker-app .mc-stat-card, + #mining-checker-app .mc-target-card, + #mining-checker-app .mc-mini-card, + #mining-checker-app .mc-display-field { + padding: 12px; + border-radius: 16px; + } + + #mining-checker-app .mc-stat-value { + font-size: clamp(1.45rem, 8vw, 1.85rem); + overflow-wrap: anywhere; + } + + #mining-checker-app h2 { + font-size: 1.25rem; + } + + #mining-checker-app h3 { + font-size: 1.05rem; + } + + #mining-checker-app .mc-text, + #mining-checker-app p, + #mining-checker-app td, + #mining-checker-app th, + #mining-checker-app label, + #mining-checker-app summary, + #mining-checker-app pre { + font-size: 0.92rem; + } + + #mining-checker-app .mc-input, + #mining-checker-app .mc-select, + #mining-checker-app .mc-textarea, + #mining-checker-app .mc-file { + min-width: 0; + padding: 10px 12px; + border-radius: 12px; + font-size: 16px; + } + + #mining-checker-app .mc-inline-fields, + #mining-checker-app .mc-debug-tools, + #mining-checker-app .mc-debug-view-switch { + gap: 8px; + } + + #mining-checker-app .mc-inline-fields > .mc-field { + flex-basis: 100%; + min-width: 0; + } + + #mining-checker-app .mc-badge { + padding: 7px 10px; + font-size: 0.78rem; + } + + #mining-checker-app .mc-table-shell { + width: 100%; + max-width: 100%; + border-radius: 16px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + #mining-checker-app .mc-table { + max-width: none; + min-width: 640px; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 9px 10px; + font-size: 0.86rem; + } + + #mining-checker-app .mc-chart svg { + height: 180px; + } + + #mining-checker-app .mc-modal-backdrop { + align-items: stretch; + padding: 8px; + } + + #mining-checker-app .mc-modal { + width: 100%; + max-height: calc(100vh - 16px); + padding: 14px; + border-radius: 18px; + } } diff --git a/public/assets/apps/mining-checker/app.js b/public/assets/apps/mining-checker/app.js index d7b9f2ac..14782404 100644 --- a/public/assets/apps/mining-checker/app.js +++ b/public/assets/apps/mining-checker/app.js @@ -1,298 +1,3188 @@ (function () { - const form = document.getElementById('mining-settings-form'); - const statsGrid = document.getElementById('stats-grid'); - const historyList = document.getElementById('history-list'); - const snapshotButton = document.getElementById('snapshot-button'); - const statTemplate = document.getElementById('stat-card-template'); - const presetButtons = Array.from(document.querySelectorAll('[data-preset]')); + const root = document.getElementById('mining-checker-app'); + if (!root || !window.React || !window.ReactDOM) { + return; + } - if (!form || !statsGrid || !historyList || !snapshotButton || !statTemplate) { - return; + const h = React.createElement; + const { useEffect, useMemo, useRef, useState } = React; + const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; + const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; + const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview'; + const configuredSections = (() => { + try { + const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map((section) => { + const key = section && typeof section.key === 'string' ? section.key.trim() : ''; + const label = section && typeof section.label === 'string' ? section.label.trim() : ''; + return key && label ? [key, label] : null; + }) + .filter(Boolean); + } catch (error) { + return []; + } + })(); + const initialDebugMode = document.body && document.body.dataset.moduleDebugEnabled === '1'; + function getCookie(name) { + const pattern = `; ${document.cookie}`; + const parts = pattern.split(`; ${name}=`); + if (parts.length < 2) { + return ''; + } + return decodeURIComponent(parts.pop().split(';').shift() || ''); + } + + function setCookie(name, value, maxAgeSeconds) { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; samesite=lax`; + } + const debugBus = window.__nexusDebugBus || { + enabled: initialDebugMode, + listener: null, + sequence: 0, + }; + window.__nexusDebugBus = debugBus; + const browserTimezone = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'; + } catch (_error) { + return 'Europe/Berlin'; + } + })(); + + function emitDebug(entry) { + debugBus.sequence += 1; + const payload = { + id: debugBus.sequence, + time: new Date().toISOString(), + ...entry, + }; + + if (typeof debugBus.listener === 'function') { + debugBus.listener(payload); + } + } + + function emitServerTraceEntries(trace, meta) { + if (!Array.isArray(trace) || !trace.length) { + emitDebug({ + type: meta && meta.type ? meta.type : 'server:trace-empty', + source: meta && meta.source ? meta.source : null, + message: 'Keine Server-Debug-Eintraege vorhanden.', + }); + return; } - const presets = { - doge: { - project_name: 'DOGE Mining', - coin_symbol: 'DOGE', - algorithm: 'Scrypt', - currency: 'EUR', - hashrate: 950, - hashrate_unit: 'MH/s', - network_hashrate: 920, - network_hashrate_unit: 'TH/s', - block_reward: 10000, - block_time_seconds: 60, - coin_price: 0.14, - power_watts: 3420, - electricity_price: 0.32, - pool_fee_percent: 1.5, - hardware_cost: 8400, - }, - ltc: { - project_name: 'LTC Mining', - coin_symbol: 'LTC', - algorithm: 'Scrypt', - currency: 'EUR', - hashrate: 950, - hashrate_unit: 'MH/s', - network_hashrate: 1.35, - network_hashrate_unit: 'PH/s', - block_reward: 6.25, - block_time_seconds: 150, - coin_price: 82, - power_watts: 3420, - electricity_price: 0.32, - pool_fee_percent: 1.5, - hardware_cost: 8400, - }, - }; + trace.forEach((item) => { + emitDebug({ + type: `server:${item.event || 'trace'}`, + source: meta && meta.source ? meta.source : null, + server_time: item.time || null, + ...(item.context && typeof item.context === 'object' ? item.context : {}), + }); + }); + } - const fmtNumber = (value, digits = 4) => { - if (value === null || value === undefined || value === '') { - return 'n/a'; - } - - return Number(value).toLocaleString('de-DE', { - minimumFractionDigits: 0, - maximumFractionDigits: digits, + async function loadLatestDebugTrace() { + try { + const response = await fetch(`${apiBase}/debug/latest`, { + headers: { 'X-Mining-Debug': '1' }, + }); + const payload = await response.json().catch(() => ({})); + if (payload && payload.data && Array.isArray(payload.data.entries)) { + emitServerTraceEntries(payload.data.entries, { + type: 'server:trace-latest', + source: payload.data.file || null, }); - }; + } + } catch (error) { + emitDebug({ + type: 'server:trace-latest-error', + message: error && error.message ? error.message : 'Latest-Debug konnte nicht geladen werden', + }); + } + } - const fmtMoney = (value, currency) => { - if (value === null || value === undefined || currency === '') { - return 'n/a'; - } + function cx() { + return Array.from(arguments).filter(Boolean).join(' '); + } - return new Intl.NumberFormat('de-DE', { - style: 'currency', - currency, - maximumFractionDigits: 4, - }).format(Number(value)); - }; + function fmtNumber(value, digits) { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + return Number(value).toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: digits === undefined ? 6 : digits, + }); + } - const fmtDateTime = (value) => { - if (!value) { - return 'n/a'; - } + function fmtMoney(value, currency) { + if (value === null || value === undefined || !currency) { + return 'n/a'; + } + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency, + maximumFractionDigits: 4, + }).format(Number(value)); + } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } + function recentMeasurementWindow(rows, windowDays) { + if (!Array.isArray(rows) || rows.length === 0) { + return []; + } - return new Intl.DateTimeFormat('de-DE', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(date); - }; + const normalizedWindowDays = Number(windowDays); + if (!Number.isFinite(normalizedWindowDays) || normalizedWindowDays <= 0) { + return rows; + } - const toHashrateHps = (value, unit) => { - const numericValue = Number(value || 0); - if (!Number.isFinite(numericValue) || numericValue <= 0) { - return 0; - } + const sortedRows = rows + .filter((row) => row && row.measured_at) + .slice() + .sort((left, right) => new Date(left.measured_at).getTime() - new Date(right.measured_at).getTime()); + if (sortedRows.length === 0) { + return rows; + } - const multipliers = { - 'H/S': 1, - 'KH/S': 1e3, - 'MH/S': 1e6, - 'GH/S': 1e9, - 'TH/S': 1e12, - 'PH/S': 1e15, - 'EH/S': 1e18, - }; + const latestTs = new Date(sortedRows[sortedRows.length - 1].measured_at).getTime(); + if (!Number.isFinite(latestTs)) { + return sortedRows; + } - return numericValue * (multipliers[String(unit || 'MH/s').toUpperCase()] || 1e6); - }; + const minTs = latestTs - (normalizedWindowDays * 86400 * 1000); + const filteredRows = sortedRows.filter((row) => { + const measuredTs = new Date(row.measured_at).getTime(); + return Number.isFinite(measuredTs) && measuredTs >= minTs; + }); - const calculateMetrics = (settings) => { - const hashrate = toHashrateHps(settings.hashrate, settings.hashrate_unit); - const networkHashrate = toHashrateHps(settings.network_hashrate, settings.network_hashrate_unit); - const powerWatts = Math.max(0, Number(settings.power_watts || 0)); - const electricityPrice = Math.max(0, Number(settings.electricity_price || 0)); - const poolFeePercent = Math.max(0, Math.min(100, Number(settings.pool_fee_percent || 0))); - const coinPrice = Math.max(0, Number(settings.coin_price || 0)); - const blockReward = Math.max(0, Number(settings.block_reward || 0)); - const blockTimeSeconds = Math.max(1, Number(settings.block_time_seconds || 60)); - const hardwareCost = Math.max(0, Number(settings.hardware_cost || 0)); + return filteredRows.length ? filteredRows : [sortedRows[sortedRows.length - 1]]; + } - const share = networkHashrate > 0 ? hashrate / networkHashrate : null; - const blocksPerDay = 86400 / blockTimeSeconds; - const dailyCoinsGross = share !== null ? share * blocksPerDay * blockReward : null; - const dailyCoinsNet = dailyCoinsGross !== null ? dailyCoinsGross * (1 - poolFeePercent / 100) : null; - const dailyRevenue = dailyCoinsNet !== null ? dailyCoinsNet * coinPrice : null; - const dailyPowerCost = (powerWatts / 1000) * 24 * electricityPrice; - const dailyProfit = dailyRevenue !== null ? dailyRevenue - dailyPowerCost : null; - const monthlyProfit = dailyProfit !== null ? dailyProfit * 30 : null; - const annualProfit = dailyProfit !== null ? dailyProfit * 365 : null; - const breakEvenDays = dailyProfit !== null && dailyProfit > 0 && hardwareCost > 0 ? hardwareCost / dailyProfit : null; + function parseStoredUtcDate(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + let normalized = raw.replace(' ', 'T'); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + normalized = `${normalized}T00:00:00Z`; + } else if (!/[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized)) { + normalized = `${normalized}Z`; + } + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } - return { - daily_coins_net: dailyCoinsNet, - daily_revenue: dailyRevenue, - daily_power_cost: dailyPowerCost, - daily_profit: dailyProfit, - monthly_profit: monthlyProfit, - annual_profit: annualProfit, - break_even_days: breakEvenDays, - share_ratio: share, - }; - }; + function formatDateByParts(value, includeTime) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return value ? String(value).replace('T', ' ').slice(0, includeTime ? 16 : 10) : 'n/a'; + } + const parts = new Intl.DateTimeFormat('de-DE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {}), + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return includeTime + ? `${map.day}.${map.month}.${map.year} ${map.hour}:${map.minute}` + : `${map.day}.${map.month}.${map.year}`; + } - const showToast = (message) => { - const node = document.createElement('div'); - node.className = 'toast'; - node.textContent = message; - document.body.appendChild(node); + function toDateTimeLocalValue(value) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return ''; + } + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } - window.setTimeout(() => { - node.remove(); - }, 2400); - }; + function nowDateTimeLocalValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } - const readForm = () => Object.fromEntries(new FormData(form).entries()); + function todayLocalDateValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; + } - const writeForm = (settings) => { - Object.entries(settings).forEach(([key, value]) => { - const field = form.elements.namedItem(key); - if (!(field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement)) { - return; - } + function fmtDate(value) { + if (!value) { + return 'n/a'; + } + return formatDateByParts(value, true); + } - field.value = String(value ?? ''); + function fmtDateTime(value) { + if (!value) { + return 'n/a'; + } + return formatDateByParts(value, true); + } + + async function request(path, options) { + const requestOptions = options && typeof options === 'object' ? { ...options } : {}; + const debugEnabled = !!debugBus.enabled; + const timeoutMs = typeof requestOptions.timeoutMs === 'number' + ? (debugEnabled ? Math.max(requestOptions.timeoutMs, 20000) : requestOptions.timeoutMs) + : (debugEnabled ? 20000 : 8000); + delete requestOptions.timeoutMs; + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + const requestUrl = path; + const headers = { ...(requestOptions.headers || {}) }; + if (debugEnabled) { + headers['X-Mining-Debug'] = '1'; + } + requestOptions.headers = headers; + + emitDebug({ + type: 'request:start', + method: requestOptions.method || 'GET', + url: requestUrl, + body: typeof requestOptions.body === 'string' ? requestOptions.body.slice(0, 2000) : null, + }); + + let response; + try { + response = await fetch(requestUrl, { + ...requestOptions, + credentials: 'same-origin', + redirect: 'manual', + signal: controller.signal, + }); + } catch (error) { + window.clearTimeout(timeoutId); + if (error && error.name === 'AbortError') { + emitDebug({ + type: 'request:timeout', + method: requestOptions.method || 'GET', + url: requestUrl, + timeout_ms: timeoutMs, }); - }; + if (debugEnabled) { + loadLatestDebugTrace(); + } + throw new Error('API request timeout'); + } + emitDebug({ + type: 'request:error', + method: requestOptions.method || 'GET', + url: requestUrl, + page_url: window.location.href, + online: window.navigator ? window.navigator.onLine : null, + message: error && error.message ? error.message : 'Fetch fehlgeschlagen', + }); + throw error; + } - const renderStats = (settings, metrics) => { - const currency = String(settings.currency || 'EUR').toUpperCase(); - const cards = [ - ['Taegliche Coins netto', fmtNumber(metrics.daily_coins_net, 6), `${settings.coin_symbol || 'COIN'} pro Tag`], - ['Taeglicher Umsatz', fmtMoney(metrics.daily_revenue, currency), 'vor Stromkosten'], - ['Taeglicher Strom', fmtMoney(metrics.daily_power_cost, currency), `${fmtNumber(settings.power_watts, 0)} W @ ${fmtMoney(settings.electricity_price, currency)}/kWh`], - ['Taeglicher Profit', fmtMoney(metrics.daily_profit, currency), 'nach Pool Fee und Strom'], - ['Monatlicher Profit', fmtMoney(metrics.monthly_profit, currency), '30 Tage Hochrechnung'], - ['Jaehrlicher Profit', fmtMoney(metrics.annual_profit, currency), '365 Tage Hochrechnung'], - ['ROI / Break-even', metrics.break_even_days ? `${fmtNumber(metrics.break_even_days, 1)} Tage` : 'n/a', 'bezogen auf Hardwarekosten'], - ['Rig Anteil am Netzwerk', metrics.share_ratio ? `${fmtNumber(metrics.share_ratio * 100, 8)} %` : 'n/a', 'Hashrate Share'], + window.clearTimeout(timeoutId); + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + const location = response.headers ? response.headers.get('Location') : ''; + emitDebug({ + type: 'request:redirect', + method: requestOptions.method || 'GET', + url: requestUrl, + status: response.status, + location: location || null, + }); + throw new Error(`API request wurde weitergeleitet${location ? ` nach ${location}` : ''}. Bitte Login/Session und Proxy-Rewrite pruefen.`); + } + + const payload = await response.json().catch(() => ({})); + emitDebug({ + type: 'request:response', + method: requestOptions.method || 'GET', + url: requestUrl, + status: response.status, + ok: response.ok, + has_debug: Array.isArray(payload && payload.debug) && payload.debug.length > 0, + }); + if (debugEnabled && payload && payload.debug) { + emitServerTraceEntries(payload.debug, { + type: 'server:trace', + source: requestUrl, + }); + } + if (!response.ok) { + const context = payload && payload.context && typeof payload.context === 'object' ? payload.context : null; + const detail = context + ? [context.message, context.statement ? `SQL: ${String(context.statement).slice(0, 500)}` : null] + .filter(Boolean) + .join(' ') + : ''; + throw new Error([payload.error || 'API request failed', detail].filter(Boolean).join(' ')); + } + if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { + return payload.data; + } + return payload; + } + + function normalizeBootstrap(data, projectKey) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + project: normalized.project || { project_key: projectKey }, + settings: normalized.settings || { + project_key: projectKey, + baseline_measured_at: '', + baseline_coins_total: '', + daily_cost_amount: '', + daily_cost_currency: 'EUR', + report_currency: 'EUR', + crypto_currency: 'DOGE', + preferred_currencies: ['DOGE', 'USD', 'EUR'], + cost_plans: [], + currencies: [], + payouts: [], + miner_offers: [], + purchased_miners: [], + measurement_rates: [], + }, + wallet_snapshots: Array.isArray(normalized.wallet_snapshots) ? normalized.wallet_snapshots : [], + measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [], + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [], + fx_snapshots: normalized.fx_snapshots && typeof normalized.fx_snapshots === 'object' ? normalized.fx_snapshots : {}, + summary: normalized.summary || { + latest_measurement: null, + baseline: normalized.settings || null, + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + payouts: { + total_count: 0, + total_coins: 0, + current_visible_coins: null, + current_effective_coins: null, + wallet_balances: {}, + wallet_balance_current_asset: null, + holdings_current_asset: null, + }, + current_hashrate_mh: null, + miner_offers: [], + }, + }; + } + + function normalizeSchemaStatus(data) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + required_tables: Array.isArray(normalized.required_tables) ? normalized.required_tables : [], + present_tables: Array.isArray(normalized.present_tables) ? normalized.present_tables : [], + missing_tables: Array.isArray(normalized.missing_tables) ? normalized.missing_tables : [], + pending_upgrades: Array.isArray(normalized.pending_upgrades) ? normalized.pending_upgrades : [], + present_count: typeof normalized.present_count === 'number' ? normalized.present_count : 0, + missing_count: typeof normalized.missing_count === 'number' ? normalized.missing_count : 0, + pending_upgrade_count: typeof normalized.pending_upgrade_count === 'number' ? normalized.pending_upgrade_count : 0, + all_present: !!normalized.all_present, + }; + } + + function normalizeOcrPreview(data) { + const normalized = data && typeof data === 'object' ? data : {}; + const suggested = normalized.suggested && typeof normalized.suggested === 'object' + ? normalized.suggested + : {}; + + return { + kind: normalized.kind === 'wallet' ? 'wallet' : 'measurement', + suggested: { + measured_at: suggested.measured_at || '', + coins_total: suggested.coins_total ?? '', + price_per_coin: suggested.price_per_coin ?? '', + price_currency: suggested.price_currency || '', + note: suggested.note || '', + source: suggested.source || 'image_ocr', + }, + suggested_wallet: normalized.suggested_wallet && typeof normalized.suggested_wallet === 'object' + ? { + measured_at: normalized.suggested_wallet.measured_at || '', + total_value_amount: normalized.suggested_wallet.total_value_amount ?? '', + total_value_currency: normalized.suggested_wallet.total_value_currency || '', + wallet_balance: normalized.suggested_wallet.wallet_balance ?? '', + wallet_currency: normalized.suggested_wallet.wallet_currency || '', + balances_json: normalized.suggested_wallet.balances_json && typeof normalized.suggested_wallet.balances_json === 'object' + ? normalized.suggested_wallet.balances_json + : {}, + note: normalized.suggested_wallet.note || '', + source: normalized.suggested_wallet.source || 'image_ocr', + } + : { + measured_at: '', + total_value_amount: '', + total_value_currency: '', + wallet_balance: '', + wallet_currency: '', + balances_json: {}, + note: '', + source: 'image_ocr', + }, + confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0, + flags: Array.isArray(normalized.flags) ? normalized.flags : [], + image_path: normalized.image_path || '', + raw_text: normalized.raw_text || '', + }; + } + + function getOcrStatusMessage(preview) { + const flags = Array.isArray(preview && preview.flags) ? preview.flags : []; + const missingProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_missing:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (flags.includes('ocr_engine_missing') || missingProviders.length) { + return { + tone: 'error', + text: missingProviders.length + ? `Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Fehlend: ${missingProviders.join(', ')}. Bitte OCR.space oder Tesseract pruefen.` + : 'Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Bitte OCR.space oder Tesseract pruefen.', + }; + } + + const emptyProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_empty:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (emptyProviders.length) { + return { + tone: 'warn', + text: `Der OCR-Provider ${emptyProviders.join(', ')} hat fuer diesen Screenshot keinen verwertbaren Rohtext geliefert.`, + }; + } + + if (flags.includes('ocr_raw_text_empty')) { + return { + tone: 'warn', + text: 'Es wurde kein OCR-Rohtext erkannt. Bitte Screenshot pruefen oder optionalen OCR-Hinweistext angeben.', + }; + } + + return null; + } + + function StatCard(props) { + return h('div', { className: 'mc-stat-card' }, [ + h('div', { key: 'label', className: 'mc-kicker' }, props.label), + h('div', { key: 'value', className: 'mc-stat-value' }, props.value), + props.sub ? h('div', { key: 'sub', className: 'mc-text' }, props.sub) : null, + ]); + } + + function Badge(props) { + return h('span', { + className: cx( + 'mc-badge', + props.tone === 'warn' ? 'mc-badge--warn' : + props.tone === 'danger' ? 'mc-badge--danger' : + props.tone === 'success' ? 'mc-badge--success' : + 'mc-badge--info' + ) + }, props.children); + } + + function SectionTitle(props) { + return h('div', { className: 'mc-section-head' }, [ + h('div', { key: 'copy' }, [ + h('h2', { key: 'title', className: 'mc-section-title' }, props.title), + props.subtitle ? h('p', { key: 'subtitle', className: 'mc-text' }, props.subtitle) : null, + ]), + props.action || null, + ]); + } + + function SimpleChart(props) { + function pointTooltip(seriesLabel, point) { + const label = seriesLabel ? `${seriesLabel} · ` : ''; + return `${label}${String(point.x)}: ${fmtNumber(point.y, 6)}`; + } + + const series = Array.isArray(props.series) + ? props.series + .map((item, index) => ({ + key: item && item.key ? String(item.key) : `series-${index}`, + label: item && item.label ? String(item.label) : `Serie ${index + 1}`, + color: item && item.color ? String(item.color) : ['#60a5fa', '#2dd4bf', '#f59e0b', '#f472b6'][index % 4], + data: Array.isArray(item && item.data) + ? item.data.filter((point) => point && point.y !== null && point.y !== undefined) + : [], + })) + .filter((item) => item.data.length > 0) + : []; + const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : []; + const isMultiSeries = series.length > 0; + const activeSeries = isMultiSeries ? series : [{ + key: 'default', + label: props.yLabel || 'Wert', + color: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + data: points, + }]; + const allPoints = activeSeries.flatMap((item) => item.data); + + if (!allPoints.length) { + return h('div', { className: 'mc-empty' }, 'Keine Daten fuer diese Ansicht.'); + } + + if (props.type === 'table') { + return h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, [ + h('th', { key: 'x' }, props.xLabel || 'X'), + h('th', { key: 'y' }, props.yLabel || 'Y'), + ])), + h('tbody', { key: 'body' }, + points.map((point, index) => h('tr', { key: index }, [ + h('td', { key: 'x' }, String(point.x)), + h('td', { key: 'y' }, fmtNumber(point.y, 6)), + ])) + ), + ]), + ]); + } + + const width = 640; + const height = 220; + const padding = 24; + const values = allPoints.map((point) => Number(point.y)); + const minY = Math.min.apply(null, values); + const maxY = Math.max.apply(null, values); + const range = maxY - minY || 1; + const seriesCoords = activeSeries.map((item) => { + const stepX = item.data.length > 1 ? (width - padding * 2) / (item.data.length - 1) : 0; + const coords = item.data.map((point, index) => { + const x = padding + stepX * index; + const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); + return [x, y]; + }); + + return { + ...item, + coords, + line: coords.map((coord) => coord.join(',')).join(' '), + area: coords.length + ? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]]) + .map((coord) => coord.join(',')).join(' ') + : '', + }; + }); + + return h('div', { className: 'mc-chart space-y-3' }, [ + h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [ + h('span', { key: 'min' }, 'Min ' + fmtNumber(minY, 4)), + h('span', { key: 'max' }, 'Max ' + fmtNumber(maxY, 4)), + ]), + h('svg', { key: 'svg', viewBox: `0 0 ${width} ${height}`, className: 'overflow-visible' }, [ + h('g', { key: 'grid', stroke: 'rgba(255,255,255,0.08)' }, [ + h('line', { key: 'top', x1: padding, x2: width - padding, y1: padding, y2: padding }), + h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }), + h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }), + ]), + props.type === 'bar' && !isMultiSeries + ? h('g', { key: 'bars' }, seriesCoords[0].coords.map((coord, index) => { + const barWidth = Math.max(10, (((width - padding * 2) / Math.max(seriesCoords[0].coords.length - 1, 1)) * 0.6) || 24); + return h('rect', { + key: index, + x: coord[0] - barWidth / 2, + y: coord[1], + width: barWidth, + height: height - padding - coord[1], + rx: 8, + fill: 'rgba(59, 130, 246, 0.75)', + }, [ + h('title', { key: 'title' }, pointTooltip(seriesCoords[0].label, seriesCoords[0].data[index])), + ]); + })) + : h('g', { key: 'series' }, seriesCoords.flatMap((item, index) => { + const nodes = []; + if (props.type === 'area' && !isMultiSeries && item.area) { + nodes.push(h('polygon', { + key: `${item.key}-area`, + points: item.area, + fill: 'rgba(45, 212, 191, 0.18)', + })); + } + nodes.push(h('polyline', { + key: `${item.key}-line`, + points: item.line, + fill: 'none', + stroke: item.color, + strokeWidth: 3, + strokeLinecap: 'round', + strokeLinejoin: 'round', + })); + nodes.push(h('g', { key: `${item.key}-dots` }, item.coords.map((coord, pointIndex) => h('circle', { + key: `${item.key}-${pointIndex}`, + cx: coord[0], + cy: coord[1], + r: 4, + fill: '#f8fafc', + stroke: item.color, + strokeWidth: 2, + }, [ + h('title', { key: 'title' }, pointTooltip(item.label, item.data[pointIndex])), + ])))); + return nodes; + })), + ]), + isMultiSeries + ? h('div', { key: 'legend', className: 'mc-mini-grid' }, + seriesCoords.map((item) => { + const latestPoint = item.data[item.data.length - 1] || null; + return h('div', { key: item.key, className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-kicker', style: { color: item.color } }, item.label), + h('div', { key: 'value' }, latestPoint ? `${fmtNumber(latestPoint.y, 4)} · ${latestPoint.x}` : 'n/a'), + ]); + }) + ) + : h('div', { key: 'labels', className: 'mc-mini-grid' }, + points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`)) + ), + ]); + } + + function DashboardCard(props) { + return h('div', { className: 'mc-dashboard-card' }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('div', { key: 'titles' }, [ + h('h3', { key: 'name' }, props.definition.name), + h('p', { key: 'meta', className: 'mc-kicker' }, + `${props.definition.chart_type} · ${props.definition.x_field} → ${props.definition.y_field} · ${props.definition.aggregation}`), + ]), + h(Badge, { key: 'badge' }, props.definition.is_active ? 'aktiv' : 'inaktiv'), + ]), + props.loading + ? h('div', { key: 'loading', className: 'mc-empty' }, 'Lade Dashboarddaten …') + : h(SimpleChart, { + key: 'chart', + type: props.definition.chart_type, + data: props.data || [], + xLabel: props.definition.x_field, + yLabel: props.definition.y_field, + }), + ]); + } + + function miningCheckerViewFromHref(href) { + try { + const url = new URL(href, window.location.origin); + if (url.pathname !== '/module/mining-checker') { + return null; + } + return url.searchParams.get('view') || 'overview'; + } catch (err) { + return null; + } + } + + function syncMiningCheckerTabButtons(activeTab) { + const buttons = Array.from(document.querySelectorAll('.module-tabs a[href*="/module/mining-checker"]')); + for (const button of buttons) { + const tab = miningCheckerViewFromHref(button.getAttribute('href') || ''); + if (!tab) { + continue; + } + const isActive = tab === activeTab; + button.classList.toggle('module-button--tab-active', isActive); + button.classList.toggle('module-button--tab', !isActive); + } + } + + function App() { + const [projectKey, setProjectKey] = useState(initialProjectKey); + const [activeTab, setActiveTab] = useState(initialActiveTab); + const [payload, setPayload] = useState(() => normalizeBootstrap(null, initialProjectKey)); + const [dashboardData, setDashboardData] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [schemaStatus, setSchemaStatus] = useState(normalizeSchemaStatus(null)); + const bootstrapCacheRef = useRef(new Map()); + const [initForm, setInitForm] = useState({ drop_existing: false }); + const [sqlImportFile, setSqlImportFile] = useState(null); + const [dbCheck, setDbCheck] = useState(null); + const [measurementForm, setMeasurementForm] = useState({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + const [importForm, setImportForm] = useState({ + rows_text: '', + default_currency: 'USD', + source: 'manual', + }); + const [importHelpOpen, setImportHelpOpen] = useState(false); + const [ocrForm, setOcrForm] = useState({ + image: null, + date_context: todayLocalDateValue(), + ocr_hint_text: '', + }); + const [ocrPreview, setOcrPreview] = useState(null); + const [dashboardForm, setDashboardForm] = useState({ + name: 'Neues Dashboard', + chart_type: 'line', + x_field: 'measured_at', + y_field: 'coins_total', + aggregation: 'none', + filters: { source: '', currency: '' }, + }); + const [settingsForm, setSettingsForm] = useState({ + baseline_measured_at: '', + baseline_coins_total: '', + report_currency: 'EUR', + crypto_currency: 'DOGE', + }); + const [moduleAuthForm, setModuleAuthForm] = useState({ + required: true, + users: '', + groups: '', + }); + const [fxHistory, setFxHistory] = useState([]); + const [fxSelection, setFxSelection] = useState(['DOGE', 'USD', 'EUR']); + const [reportCurrencyOverride, setReportCurrencyOverride] = useState(() => { + const value = String(getCookie('mining_checker_report_currency') || '').toUpperCase(); + return /^[A-Z0-9]{3,10}$/.test(value) ? value : ''; + }); + const [targetForm, setTargetForm] = useState({ + label: '', + target_amount_fiat: '', + currency: 'EUR', + miner_offer_id: '', + is_active: true, + sort_order: 0, + }); + const [targetModalOpen, setTargetModalOpen] = useState(false); + const [selectedMinerScenarioId, setSelectedMinerScenarioId] = useState(null); + const [minerOfferFilters, setMinerOfferFilters] = useState({ + speed_min: '', + speed_unit: 'auto', + price_max: '', + runtime_months: '', + }); + const [costPlanForm, setCostPlanForm] = useState({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_percent: '', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: 'EUR', + note: '', + is_active: true, + }); + const [costPlanModalOpen, setCostPlanModalOpen] = useState(false); + const [payoutForm, setPayoutForm] = useState({ + payout_at: '', + coins_amount: '', + payout_currency: 'DOGE', + note: '', + }); + const [payoutModalOpen, setPayoutModalOpen] = useState(false); + const [minerOfferForm, setMinerOfferForm] = useState({ + label: '', + runtime_months: '', + bonus_percent: '', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + const [minerOfferModalOpen, setMinerOfferModalOpen] = useState(false); + const [purchaseMinerModalOpen, setPurchaseMinerModalOpen] = useState(false); + const [purchaseMinerForm, setPurchaseMinerForm] = useState({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + + useEffect(() => { + debugBus.enabled = initialDebugMode; + emitDebug({ + type: 'debug:mode', + enabled: initialDebugMode, + }); + }, []); + + const measurements = Array.isArray(payload?.measurements) ? payload.measurements : []; + const latest = payload?.summary?.latest_measurement || null; + const currentSettings = payload?.settings || { + cost_plans: [], + currencies: [], + }; + const reportCurrency = reportCurrencyOverride || currentSettings.report_currency || 'EUR'; + const currentTargets = Array.isArray(payload?.summary?.targets) ? payload.summary.targets : []; + const currentDashboards = Array.isArray(payload?.dashboards) ? payload.dashboards : []; + const currencies = Array.isArray(currentSettings.currencies) && currentSettings.currencies.length + ? currentSettings.currencies + : [ + { code: 'EUR', name: 'Euro' }, + { code: 'USD', name: 'US-Dollar' }, + { code: 'DOGE', name: 'Dogecoin' }, + { code: 'BTC', name: 'Bitcoin' }, + { code: 'ETH', name: 'Ethereum' }, + { code: 'LTC', name: 'Litecoin' }, + { code: 'USDT', name: 'Tether' }, + { code: 'USDC', name: 'USD Coin' }, ]; + const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : []; + const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []; + const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; + const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; + const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; + const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; + const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency); + const renewableOfferIds = new Set( + currentMinerOffers + .filter((offer) => !!offer.auto_renew) + .map((offer) => Number(offer.id)) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const preferredCurrencyCodes = Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies.map((code) => String(code || '').toUpperCase()).filter(Boolean) + : []; + const preferredCurrencySet = new Set(preferredCurrencyCodes); + const preferredSelectableCurrencies = preferredCurrencySet.size + ? currencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : currencies; + const selectableCurrencies = preferredSelectableCurrencies.length ? preferredSelectableCurrencies : currencies; + const evaluatedMinerOffers = Array.isArray(payload?.summary?.miner_offers) ? payload.summary.miner_offers : []; + const availableMinerOffers = evaluatedMinerOffers.filter((offer) => isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance)); + const filteredMinerOffers = availableMinerOffers.filter((offer) => { + const speedMin = minerOfferFilters.speed_min === '' ? null : Number(minerOfferFilters.speed_min); + const speedUnit = String(minerOfferFilters.speed_unit || 'auto'); + const priceMax = minerOfferFilters.price_max === '' ? null : Number(minerOfferFilters.price_max); + const runtimeMonths = minerOfferFilters.runtime_months === '' ? null : Number(minerOfferFilters.runtime_months); + const offerHashrate = Number(offer.offer_hashrate_mh); + const comparablePrice = convertCurrencyValue( + offer.base_price_amount ?? offer.effective_price_amount, + offer.base_price_currency || offer.effective_price_currency, + reportCurrency + ); + const runtime = Number(offer.runtime_months); + const comparableHashrate = speedUnit === 'kh' + ? offerHashrate * 1000 + : offerHashrate; - statsGrid.innerHTML = ''; + if (Number.isFinite(speedMin) && (!Number.isFinite(comparableHashrate) || comparableHashrate < speedMin)) { + return false; + } - cards.forEach(([label, value, meta]) => { - const fragment = statTemplate.content.cloneNode(true); - fragment.querySelector('.stat-label').textContent = label; - fragment.querySelector('.stat-value').textContent = value; - fragment.querySelector('.stat-meta').textContent = meta; - statsGrid.appendChild(fragment); - }); - }; + if (Number.isFinite(priceMax) && (!Number.isFinite(comparablePrice) || comparablePrice > priceMax)) { + return false; + } - const renderHistory = (entries) => { - historyList.innerHTML = ''; + if (Number.isFinite(runtimeMonths) && runtime !== runtimeMonths) { + return false; + } - if (!Array.isArray(entries) || entries.length === 0) { - const empty = document.createElement('div'); - empty.className = 'history-empty'; - empty.textContent = 'Noch keine Snapshots gespeichert.'; - historyList.appendChild(empty); - return; + return true; + }); + const speedUnits = ['kH/s', 'MH/s']; + const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto); + const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto); + const preferredSelectableFiatCurrencies = preferredCurrencySet.size + ? fiatCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : fiatCurrencies; + const preferredSelectableCryptoCurrencies = preferredCurrencySet.size + ? cryptoCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : cryptoCurrencies; + const selectableFiatCurrencies = preferredSelectableFiatCurrencies.length ? preferredSelectableFiatCurrencies : fiatCurrencies; + const selectableCryptoCurrencies = preferredSelectableCryptoCurrencies.length ? preferredSelectableCryptoCurrencies : cryptoCurrencies; + const selectedMinerScenario = availableMinerOffers.find((offer) => String(offer.id) === String(selectedMinerScenarioId)) || null; + const activeMinerRows = currentPurchasedMiners.map((miner) => ({ + id: `purchase-${miner.id}`, + source: 'miete', + starts_at: miner.purchased_at, + label: miner.label, + runtime_months: miner.runtime_months, + auto_renew: !!miner.auto_renew, + effective_amount: miner.total_cost_amount, + effective_currency: miner.currency, + base_amount: miner.reference_price_amount, + base_currency: miner.reference_price_currency, + miner_id: miner.id, + miner_offer_id: miner.miner_offer_id, + payment_type: miner.reference_price_currency + && miner.currency + && String(miner.reference_price_currency).toUpperCase() !== String(miner.currency).toUpperCase() + ? 'crypto' + : 'fiat', + is_active: miner.is_active !== false, + can_toggle_auto_renew: Number(miner.runtime_months) > 0 && renewableOfferIds.has(Number(miner.miner_offer_id)), + hashrate_text: formatHashrateWithBonus(miner.mining_speed_value, miner.mining_speed_unit, miner.bonus_speed_value, miner.bonus_speed_unit), + type_label: 'Aus Angebot gemietet', + })).concat(currentCostPlans.map((plan) => ({ + id: `plan-${plan.id}`, + source: 'manual', + starts_at: plan.starts_at, + label: plan.label, + runtime_months: plan.runtime_months, + auto_renew: !!plan.auto_renew, + effective_amount: plan.total_cost_amount, + effective_currency: plan.currency, + base_amount: plan.base_price_amount, + base_currency: currentSettings.report_currency || 'EUR', + payment_type: plan.payment_type, + is_active: !!plan.is_active, + can_toggle_auto_renew: false, + hashrate_text: formatHashrateWithBonus(plan.mining_speed_value, plan.mining_speed_unit, plan.bonus_speed_value, plan.bonus_speed_unit), + type_label: 'Manuell eingetragen', + }))).sort((left, right) => String(right.starts_at || '').localeCompare(String(left.starts_at || ''))); + + useEffect(() => { + if (reportCurrencyOverride) { + setCookie('mining_checker_report_currency', reportCurrencyOverride, 60 * 60 * 24 * 30); + } + }, [reportCurrencyOverride]); + + useEffect(() => { + if (selectedMinerScenarioId === null) { + return; + } + + const exists = availableMinerOffers.some((offer) => String(offer.id) === String(selectedMinerScenarioId)); + if (!exists) { + setSelectedMinerScenarioId(null); + } + }, [availableMinerOffers, selectedMinerScenarioId]); + + useEffect(() => { + if (!purchaseMinerModalOpen) { + return; + } + + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)) + || availableMinerOffers[0] + || null; + if (!selectedOffer) { + return; + } + + setPurchaseMinerForm((current) => ({ + ...current, + offer_id: current.offer_id || String(selectedOffer.id), + base_offer_id: current.base_offer_id || String(selectedOffer.base_offer_id || selectedOffer.id || ''), + label: current.label || String(selectedOffer.label || ''), + mining_speed_value: current.mining_speed_value || String(selectedOffer.mining_speed_value || ''), + mining_speed_unit: current.mining_speed_unit || String(selectedOffer.mining_speed_unit || ''), + bonus_percent: current.bonus_percent || (selectedOffer.bonus_percent !== null && selectedOffer.bonus_percent !== undefined ? String(selectedOffer.bonus_percent) : ''), + currency: current.currency || (!selectedOffer.auto_renew ? (currentSettings.crypto_currency || 'DOGE') : (selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD')), + total_cost_amount: current.total_cost_amount || (selectedOffer.effective_price_amount !== null && selectedOffer.effective_price_amount !== undefined ? String(selectedOffer.effective_price_amount) : ''), + reference_price_amount: current.reference_price_amount || (selectedOffer.reference_price_amount !== null && selectedOffer.reference_price_amount !== undefined ? String(selectedOffer.reference_price_amount) : ''), + reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '', + auto_renew: current.auto_renew || !!selectedOffer.auto_renew, + })); + }, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id, currentSettings.crypto_currency]); + + function measurementFxRate(measurementId, fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const measurement = measurements.find((row) => Number(row.id) === Number(measurementId)); + const fetchId = measurement && measurement.fx_fetch_id !== null && measurement.fx_fetch_id !== undefined + ? String(measurement.fx_fetch_id) + : ''; + const snapshots = payload && payload.fx_snapshots && typeof payload.fx_snapshots === 'object' + ? payload.fx_snapshots + : {}; + const snapshot = fetchId && snapshots[fetchId] && typeof snapshots[fetchId] === 'object' + ? snapshots[fetchId] + : null; + + if (snapshot) { + const baseCurrency = String(snapshot.base_currency || '').toUpperCase(); + const rates = snapshot.rates && typeof snapshot.rates === 'object' ? snapshot.rates : {}; + const directRate = from === baseCurrency ? rates[to] : null; + if (Number.isFinite(Number(directRate)) && Number(directRate) > 0) { + return Number(directRate); } - entries.forEach((entry) => { - const item = document.createElement('article'); - item.className = 'history-item'; - item.innerHTML = ` -
- ${entry.project_name || 'Snapshot'} - ${fmtDateTime(entry.created_at)} -
-
- ${entry.coin_symbol || 'COIN'} - Profit/Tag: ${fmtMoney(entry.metrics?.daily_profit, entry.currency || 'EUR')} - ROI: ${entry.metrics?.break_even_days ? `${fmtNumber(entry.metrics.break_even_days, 1)} Tage` : 'n/a'} -
- ${entry.note ? `

${entry.note}

` : ''} - `; - historyList.appendChild(item); - }); - }; - - const load = async () => { - const response = await fetch('/api/mining-checker/'); - const data = await response.json(); - writeForm(data.settings || {}); - renderStats(data.settings || {}, data.metrics || {}); - renderHistory(data.history || []); - }; - - const saveSettings = async () => { - const response = await fetch('/api/mining-checker/', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(readForm()), - }); - const data = await response.json(); - writeForm(data.settings || {}); - renderStats(data.settings || {}, data.metrics || {}); - renderHistory(data.history || []); - showToast('Mining-Checker gespeichert'); - }; - - const saveSnapshot = async () => { - const note = window.prompt('Optionaler Snapshot-Hinweis', ''); - if (note === null) { - return; + const inverseRate = to === baseCurrency ? rates[from] : null; + if (Number.isFinite(Number(inverseRate)) && Number(inverseRate) > 0) { + return 1 / Number(inverseRate); } - const response = await fetch('/api/mining-checker/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - settings: readForm(), - note, - }), - }); - const data = await response.json(); - writeForm(data.settings || {}); - renderStats(data.settings || {}, data.metrics || {}); - renderHistory(data.history || []); - showToast('Snapshot gespeichert'); - }; + const fromRate = from === baseCurrency ? 1 : Number(rates[from]); + const toRate = to === baseCurrency ? 1 : Number(rates[to]); + if (Number.isFinite(fromRate) && Number.isFinite(toRate) && fromRate > 0 && toRate > 0) { + return toRate / fromRate; + } + } + + const priceQuotes = measurement && measurement.price_quotes && typeof measurement.price_quotes === 'object' + ? measurement.price_quotes + : null; + if (priceQuotes && from === 'DOGE') { + const directQuote = Number(priceQuotes[to]); + if (Number.isFinite(directQuote) && directQuote > 0) { + return directQuote; + } + } + if (priceQuotes && to === 'DOGE') { + const inverseQuote = Number(priceQuotes[from]); + if (Number.isFinite(inverseQuote) && inverseQuote > 0) { + return 1 / inverseQuote; + } + } + + return null; + } + + function convertMeasurementMoney(measurement, value, targetCurrency) { + if (!measurement || value === null || value === undefined) { + return null; + } + + const sourceCurrency = String(measurement.effective_price_currency || measurement.price_currency || '').toUpperCase(); + const target = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!sourceCurrency || !target || !Number.isFinite(numericValue)) { + return null; + } + if (sourceCurrency === target) { + return numericValue; + } + + const rate = measurementFxRate(measurement.id, sourceCurrency, target) ?? latestFxHistoryRate(sourceCurrency, target); + return rate === null ? null : numericValue * rate; + } + + function convertCurrencyValue(value, sourceCurrency, targetCurrency) { + const from = String(sourceCurrency || '').toUpperCase(); + const to = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!from || !to || !Number.isFinite(numericValue)) { + return null; + } + if (from === to) { + return numericValue; + } + + const rate = latest && latest.id + ? (measurementFxRate(latest.id, from, to) ?? latestFxHistoryRate(from, to)) + : latestFxHistoryRate(from, to); + return rate === null ? null : numericValue * rate; + } + + function walletAssetBalance(snapshot, currency) { + const code = String(currency || '').toUpperCase(); + if (!snapshot || !code) { + return null; + } + + const assets = snapshot.balances_json && typeof snapshot.balances_json === 'object' ? snapshot.balances_json : {}; + const asset = assets[code] ?? assets[code.toLowerCase()] ?? assets[code.toUpperCase()]; + const snapshotCurrency = String(snapshot.wallet_currency || '').toUpperCase(); + const rawBalance = asset && typeof asset === 'object' + ? asset.balance + : (asset ?? (snapshotCurrency === code ? snapshot.wallet_balance : null)); + const balance = Number(rawBalance); + return Number.isFinite(balance) ? balance : null; + } + + function isOfferAvailableForWallet(offer, miningCurrency, walletBalance) { + if (!offer || !offer.is_active) { + return false; + } + + if (String(offer.payment_type || '').toLowerCase() !== 'crypto') { + return true; + } + + const currency = String(offer.effective_price_currency || offer.price_currency || '').toUpperCase(); + const expectedCurrency = String(miningCurrency || '').toUpperCase(); + const price = Number(offer.effective_price_amount); + const balance = Number(walletBalance); + + if (!currency || !expectedCurrency || currency !== expectedCurrency || !Number.isFinite(price) || !Number.isFinite(balance)) { + return true; + } + + return price <= balance + 0.00000001; + } + + function latestFxHistoryRate(fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const rows = Array.isArray(fxHistory) ? fxHistory : []; + for (const row of rows) { + const rowBase = String(row.base_currency || '').toUpperCase(); + const rowTarget = String(row.target_currency || row.currency_code || '').toUpperCase(); + const rowRate = Number(row.rate); + if (!Number.isFinite(rowRate) || rowRate <= 0) { + continue; + } + + if (rowBase === from && rowTarget === to) { + return rowRate; + } + if (rowBase === to && rowTarget === from) { + return 1 / rowRate; + } + } + + return null; + } + + async function loadSchemaStatus(key) { + try { + const schema = await request(`${apiBase}/projects/${encodeURIComponent(key)}/schema-status`, { timeoutMs: 4000 }); + setSchemaStatus(normalizeSchemaStatus(schema)); + } catch (err) { + setSchemaStatus(normalizeSchemaStatus(null)); + } + } + + async function loadBootstrap(key) { + const cacheKey = `${key}:${activeTab || 'overview'}`; + const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null; + + setLoading(!cachedPayload); + setError(''); + if (cachedPayload) { + setPayload(cachedPayload); + } else { + setPayload((previous) => previous || normalizeBootstrap(null, key)); + } + let loadGuardTriggered = false; + const loadGuard = window.setTimeout(() => { + loadGuardTriggered = true; + setLoading(false); + setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key)); + setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); + }, 12000); + + try { + if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) { + setPayload(normalizeBootstrap(null, key)); + setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.'); + return; + } + + const params = new URLSearchParams({ view: activeTab || 'overview' }); + const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 10000 }); + const normalized = normalizeBootstrap(data, key); + bootstrapCacheRef.current.set(cacheKey, normalized); + setPayload(normalized); + setSettingsForm({ + baseline_measured_at: normalized.settings.baseline_measured_at || '', + baseline_coins_total: normalized.settings.baseline_coins_total || '', + report_currency: normalized.settings.report_currency || 'EUR', + crypto_currency: normalized.settings.crypto_currency || 'DOGE', + }); + setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length + ? normalized.settings.preferred_currencies + : ['DOGE', 'USD', 'EUR']); + setTargetForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + setCostPlanForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + } catch (err) { + setError(err.message); + setPayload(normalizeBootstrap(null, key)); + } finally { + window.clearTimeout(loadGuard); + if (!loadGuardTriggered) { + setLoading(false); + } + } + } + + useEffect(() => { + loadBootstrap(projectKey); + }, [projectKey, activeTab]); + + useEffect(() => { + if (activeTab !== 'mining') { + return; + } + loadFxHistory(projectKey); + }, [activeTab, projectKey]); + + useEffect(() => { + syncMiningCheckerTabButtons(activeTab); + + function handleNavigationClick(event) { + const link = event.target instanceof Element + ? event.target.closest('.module-tabs a[href*="/module/mining-checker"]') + : null; + if (!link) { + return; + } + + const nextTab = miningCheckerViewFromHref(link.getAttribute('href') || ''); + if (!nextTab || nextTab === activeTab) { + return; + } - form.addEventListener('submit', async (event) => { event.preventDefault(); - await saveSettings(); - }); + window.history.pushState({ miningCheckerView: nextTab }, '', `/module/mining-checker?view=${encodeURIComponent(nextTab)}`); + setActiveTab(nextTab); + } - snapshotButton.addEventListener('click', async () => { - await saveSnapshot(); - }); + function handlePopState() { + const nextTab = miningCheckerViewFromHref(window.location.href) || 'overview'; + setActiveTab(nextTab); + } - presetButtons.forEach((button) => { - button.addEventListener('click', () => { - const preset = button.dataset.preset; - if (!preset || preset === 'custom') { - showToast('Aktuelle Werte bleiben unveraendert'); - return; - } + document.addEventListener('click', handleNavigationClick); + window.addEventListener('popstate', handlePopState); + return () => { + document.removeEventListener('click', handleNavigationClick); + window.removeEventListener('popstate', handlePopState); + }; + }, [activeTab]); - writeForm(presets[preset]); - renderStats(readForm(), calculateMetrics(readForm())); - showToast(`Preset ${preset.toUpperCase()} geladen`); + useEffect(() => { + loadSchemaStatus(projectKey); + loadModuleAuth(); + }, [projectKey]); + + useEffect(() => { + async function loadSavedDashboards() { + if (!payload || !currentDashboards.length) { + return; + } + + const next = {}; + for (const definition of currentDashboards) { + const params = new URLSearchParams({ + x_field: definition.x_field, + y_field: definition.y_field, + aggregation: definition.aggregation || 'none', + }); + if (definition.filters && definition.filters.source) { + params.set('source', definition.filters.source); + } + if (definition.filters && definition.filters.currency) { + params.set('currency', definition.filters.currency); + } + + try { + next[definition.id] = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboard-data?${params.toString()}`); + } catch (err) { + next[definition.id] = []; + } + } + setDashboardData(next); + } + + loadSavedDashboards(); + }, [payload, projectKey]); + + const overviewCharts = useMemo(() => { + const overviewWindowDays = Number(payload?.bootstrap_meta?.overview_window_days || 15); + const overviewRows = recentMeasurementWindow(measurements, overviewWindowDays); + const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const comparisonRows = overviewRows.filter((row) => { + const miningRate = Number(row.doge_per_hour_per_mh_interval); + const price = Number(row.effective_price_per_coin ?? row.price_per_coin); + return Number.isFinite(miningRate) && miningRate > 0 && Number.isFinite(price) && price > 0; + }); + const baseComparison = comparisonRows[0] || null; + const baseMining = baseComparison ? Number(baseComparison.doge_per_hour_per_mh_interval) : null; + const basePrice = baseComparison ? Number(baseComparison.effective_price_per_coin ?? baseComparison.price_per_coin) : null; + + return { + mining: overviewRows.map((row) => ({ x: fmtDate(row.measured_at), y: row.coins_total })), + performance: overviewRows.filter((row) => row.doge_per_day_interval !== null) + .map((row) => ({ x: fmtDate(row.measured_at), y: row.doge_per_day_interval })), + pricing: overviewRows.filter((row) => row.price_per_coin !== null) + .map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })), + miningVsPrice: baseMining && basePrice ? [ + { + key: 'mining-rate', + label: `${chartCoinCurrency}/h je MH/s Index`, + color: '#2dd4bf', + data: comparisonRows.map((row) => ({ + x: fmtDate(row.measured_at), + y: (Number(row.doge_per_hour_per_mh_interval) / baseMining) * 100, + })), + }, + { + key: 'doge-price', + label: `${chartCoinCurrency}-Kurs Index`, + color: '#f59e0b', + data: comparisonRows.map((row) => ({ + x: fmtDate(row.measured_at), + y: (Number(row.effective_price_per_coin ?? row.price_per_coin) / basePrice) * 100, + })), + }, + ] : [], + }; + }, [currentSettings.crypto_currency, measurements, payload?.bootstrap_meta?.overview_window_days]); + + async function submitMeasurement(fromPreview) { + const preview = normalizeOcrPreview(ocrPreview); + const raw = fromPreview ? { + ...preview.suggested, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + } : measurementForm; + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), }); - }); + setMessage(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.'); + setMeasurementForm({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } - form.addEventListener('input', () => { - const settings = readForm(); - renderStats(settings, calculateMetrics(settings)); - }); + async function submitWalletSnapshotFromPreview() { + const preview = normalizeOcrPreview(ocrPreview); + const raw = { + ...preview.suggested_wallet, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + }; - load().catch((error) => { - console.error(error); - showToast('Mining-Checker konnte nicht geladen werden'); - }); -}()); + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-snapshots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), + }); + setMessage('Wallet-Snapshot gespeichert.'); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteMeasurement(id) { + if (!id) { + return; + } + + if (!window.confirm('Diesen Messpunkt wirklich loeschen?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + setMessage('Messpunkt geloescht.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMeasurementImport(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(importForm), + timeoutMs: 20000, + }); + + const summary = [ + `${result.imported || 0} importiert`, + `${result.duplicates_ignored || 0} Duplikate ignoriert`, + `${result.error_count || 0} Fehler`, + ].join(', '); + + setMessage(`Import abgeschlossen: ${summary}.`); + if (!result.error_count) { + setImportForm((previous) => ({ ...previous, rows_text: '' })); + } + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadOcrPreview(file, overrides) { + const nextForm = { + ...ocrForm, + ...(overrides || {}), + image: file || null, + }; + + if (!nextForm.image) { + setOcrPreview(null); + setError('Bitte ein Bild auswaehlen.'); + return; + } + + setOcrForm(nextForm); + setSaving(true); + setError(''); + setMessage(''); + try { + const body = new FormData(); + body.append('image', nextForm.image); + body.append('date_context', nextForm.date_context); + body.append('ocr_hint_text', nextForm.ocr_hint_text); + body.append('wallet_currency_hint', currentSettings.crypto_currency || 'DOGE'); + const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, { + method: 'POST', + body, + timeoutMs: 45000, + }); + setOcrPreview(normalizeOcrPreview(data)); + setMessage('OCR-Ergebnis geladen. Bei Bedarf direkt speichern.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitDashboard(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...dashboardForm, + is_active: true, + filters: dashboardForm.filters, + }), + }); + setMessage('Dashboard gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitSettings(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseline_measured_at: settingsForm.baseline_measured_at, + baseline_coins_total: settingsForm.baseline_coins_total, + daily_cost_amount: currentSettings.daily_cost_amount, + daily_cost_currency: currentSettings.daily_cost_currency, + report_currency: settingsForm.report_currency || 'EUR', + crypto_currency: settingsForm.crypto_currency || 'DOGE', + preferred_currencies: Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies + : fxSelection, + }), + }); + setMessage('Settings gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadModuleAuth() { + try { + const auth = await request('/api/module-auth/mining-checker', { timeoutMs: 5000 }); + setModuleAuthForm({ + required: !!auth.required, + users: Array.isArray(auth.users) ? auth.users.join(', ') : '', + groups: Array.isArray(auth.groups) ? auth.groups.join(', ') : '', + }); + } catch (err) { + setError(err.message); + } + } + + async function submitModuleAuth(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request('/api/module-auth/mining-checker', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + required: !!moduleAuthForm.required, + users: moduleAuthForm.users, + groups: moduleAuthForm.groups, + }), + }); + setMessage('Modulrechte gespeichert.'); + await loadModuleAuth(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitTarget(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(targetForm), + }); + setMessage('Ziel gespeichert.'); + setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 }); + setTargetModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteTarget(target) { + const label = target?.label || 'dieses Ziel'; + if (!window.confirm(`Soll ${label} wirklich geloescht werden?`)) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets/${encodeURIComponent(target.id)}`, { + method: 'DELETE', + }); + setMessage('Ziel geloescht.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function togglePurchasedMinerAutoRenew(row) { + if (!row || !row.can_toggle_auto_renew || !row.miner_id) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/purchased-miners/${encodeURIComponent(row.miner_id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto_renew: !row.auto_renew }), + }); + setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitCostPlan(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/cost-plans`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(costPlanForm), + }); + setMessage('Miner gespeichert.'); + setCostPlanForm({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_percent: '', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: currencies[0]?.code || 'EUR', + note: '', + is_active: true, + }); + setCostPlanModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPayout(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payoutForm), + }); + setMessage('Auszahlung gespeichert.'); + setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); + setPayoutModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMinerOffer(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + const offerPayload = { + ...minerOfferForm, + base_price_currency: minerOfferForm.payment_type === 'crypto' ? 'USD' : minerOfferForm.base_price_currency, + }; + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(offerPayload), + }); + setMessage('Miner-Angebot gespeichert.'); + setMinerOfferForm({ + label: '', + runtime_months: '', + bonus_percent: '', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + setMinerOfferModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function purchaseMinerOffer(offerId, overrides) { + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers/${offerId}/purchase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(overrides || { purchased_at: nowDateTimeLocalValue() }), + }); + setMessage('Miner als gemietet erfasst.'); + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + setPurchaseMinerModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPurchaseMiner(event) { + event.preventDefault(); + if (!purchaseMinerForm.offer_id) { + setError('Bitte ein Miner-Angebot auswaehlen.'); + return; + } + + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)); + if (!selectedOffer) { + setError('Bitte ein gueltiges Miner-Angebot auswaehlen.'); + return; + } + + await purchaseMinerOffer(String(selectedOffer.base_offer_id || selectedOffer.id), { + label: purchaseMinerForm.label || null, + mining_speed_value: purchaseMinerForm.mining_speed_value || null, + mining_speed_unit: purchaseMinerForm.mining_speed_unit || null, + bonus_percent: purchaseMinerForm.bonus_percent || null, + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), + total_cost_amount: purchaseMinerForm.total_cost_amount || null, + currency: purchaseMinerForm.currency || null, + reference_price_amount: purchaseMinerForm.reference_price_amount || null, + reference_price_currency: purchaseMinerForm.reference_price_currency || null, + auto_renew: !!purchaseMinerForm.auto_renew, + note: purchaseMinerForm.note || '', + }); + } + + async function initializeModule(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/initialize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(initForm), + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} Vorhanden: ${nextStatus.present_count}/${nextStatus.required_tables.length}. ` + + (Array.isArray(result.dropped_tables) && result.dropped_tables.length + ? `Geloeschte Tabellen: ${result.dropped_tables.join(', ')}.` + : 'Keine Tabellen geloescht.') + ); + try { + await loadBootstrap(projectKey); + } catch (bootstrapError) { + setError(`Schema wurde initialisiert, aber Bootstrap-Daten konnten nicht geladen werden: ${bootstrapError.message}`); + } + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function upgradeDatabaseSchema() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/upgrade`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} ` + + (Array.isArray(result.upgraded) && result.upgraded.length + ? `Angewendete Upgrades: ${result.upgraded.join(', ')}.` + : 'Keine Upgrades erforderlich.') + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function importOldData() { + if (!window.confirm('Alte Mining-Checker Daten ueber alle Tabellen sichern, Schema neu aufbauen und danach importieren?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/rebuild-preserve-core`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const restored = result.restored && typeof result.restored === 'object' ? result.restored : {}; + const restoredParts = Object.keys(restored) + .filter((key) => Number(restored[key]) > 0) + .map((key) => `${key}: ${restored[key]}`); + setMessage( + `${result.message || 'Alte Daten wurden importiert.'} ` + + (restoredParts.length ? `Importiert: ${restoredParts.join(', ')}.` : 'Keine Altdaten gefunden.') + ); + await loadSchemaStatus(projectKey); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function migrateLegacyFxData() { + if (!window.confirm('Legacy-FX-Rates aus dem Mining-Checker nach fx-rates migrieren und Messpunkte auf die neuen fetch_id-Verweise aktualisieren?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/legacy-fx-migrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + timeoutMs: 30000, + }); + setMessage( + `${result.message || 'Legacy-FX-Rates wurden migriert.'} ` + + `Fetches gefunden: ${Number(result.legacy_fetches_found || 0)}, ` + + `neu importiert: ${Number(result.fx_fetches_imported || 0)}, ` + + `wiederverwendet: ${Number(result.fx_fetches_reused || 0)}, ` + + `Messpunkte aktualisiert: ${Number(result.measurements_updated || 0)}, ` + + `offen: ${Number(result.measurements_unresolved || 0)}.` + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function importSqlFile() { + if (!sqlImportFile) { + setError('Bitte zuerst eine SQL-Datei auswaehlen.'); + setMessage(''); + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const body = new FormData(); + body.append('sql_file', sqlImportFile); + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/sql-import`, { + method: 'POST', + body, + timeoutMs: 30000, + }); + setSqlImportFile(null); + setMessage( + `${result.message || 'SQL-Datei wurde importiert.'} ` + + `${result.statement_count || 0} Statements aus ${result.file || 'der Datei'} ausgefuehrt.` + ); + await loadSchemaStatus(projectKey); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function testDatabaseConnection() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/connection-test`); + setDbCheck(result); + setMessage(`DB-Verbindung erfolgreich. Driver: ${result.driver}, Datenbank: ${result.database}.`); + } catch (err) { + setDbCheck(null); + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadFxHistory(key) { + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(key)}/fx-history`, { timeoutMs: 6000 }); + setFxHistory(Array.isArray(result) ? result : []); + } catch (err) { + setFxHistory([]); + } + } + + function resetReportCurrencyOverride() { + setReportCurrencyOverride(''); + setCookie('mining_checker_report_currency', '', 0); + } + + function renderSharedOcrPanel() { + const preview = normalizeOcrPreview(ocrPreview); + const isWalletPreview = preview.kind === 'wallet'; + const hasMiningSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null; + const hasWalletSuggestion = preview.suggested_wallet.wallet_balance !== '' && preview.suggested_wallet.wallet_balance !== null; + const hasUsableOcrSuggestion = isWalletPreview + ? hasWalletSuggestion + : hasMiningSuggestion; + const ocrStatus = getOcrStatusMessage(preview); + + return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [ + h('div', { + key: 'ocr-form', + className: 'mc-form', + }, [ + fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })), + ]), + saving && ocrForm.image + ? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …') + : null, + ocrPreview + ? h('div', { key: 'ocr-preview', className: 'mc-form' }, [ + h('div', { key: 'badges', className: 'mc-inline-row' }, [ + h(Badge, { key: 'kind', tone: isWalletPreview ? 'info' : 'success' }, isWalletPreview ? 'Wallet' : 'Mining'), + h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), + ]), + isWalletPreview + ? h('div', { key: 'wallet-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Wallet-Snapshot'), + displayField('Mining-Waehrung im Wallet', `${fmtNumber(preview.suggested_wallet.wallet_balance, 8)} ${preview.suggested_wallet.wallet_currency || currentSettings.crypto_currency || 'DOGE'}`), + displayField('Gesamtwert', preview.suggested_wallet.total_value_amount !== '' && preview.suggested_wallet.total_value_amount !== null + ? `${fmtNumber(preview.suggested_wallet.total_value_amount, 4)} ${preview.suggested_wallet.total_value_currency || ''}`.trim() + : 'n/a'), + displayField('Erkannte Wallet-Assets', Object.entries(preview.suggested_wallet.balances_json || {}) + .slice(0, 8) + .map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return `${fmtNumber(balance, 8)} ${code}${priceAmount ? ` @ ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim() : ''}`; + }) + .join(' · ') || 'n/a'), + ]) + : h('div', { key: 'measurement-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Mining-Messpunkt'), + displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)), + displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)), + displayField('Waehrung', preview.suggested.price_currency || 'n/a'), + ]), + ocrStatus + ? h('div', { + key: 'ocr-status', + className: cx( + 'mc-alert', + ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning' + ), + }, ocrStatus.text) + : null, + !hasUsableOcrSuggestion + ? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' }, + 'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder die Daten manuell erfassen.') + : null, + h('button', { + key: 'confirm', + type: 'button', + className: 'mc-button mc-button--primary', + onClick: () => isWalletPreview ? submitWalletSnapshotFromPreview() : submitMeasurement(true), + disabled: saving || !hasUsableOcrSuggestion, + }, saving ? 'Speichert …' : 'Ergebnis speichern'), + isWalletPreview && hasMiningSuggestion + ? h('button', { + key: 'force-mining', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => submitMeasurement(true), + disabled: saving, + }, 'Als Mining speichern') + : null, + !isWalletPreview && hasWalletSuggestion + ? h('button', { + key: 'force-wallet', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => submitWalletSnapshotFromPreview(), + disabled: saving, + }, 'Als Wallet speichern') + : null, + ]) + : h('div', { key: 'ocr-empty', className: 'mc-empty' }, + 'Noch kein Screenshot ausgewaehlt.'), + ]); + } + return h('div', { + className: 'mc-grid-bg', + }, [ + h('div', { key: 'shell', className: 'mc-shell mc-stack' }, [ + error ? h('div', { key: 'error', className: 'mc-alert mc-alert--error' }, error) : null, + message ? h('div', { key: 'message', className: 'mc-alert mc-alert--success' }, message) : null, + loading ? h('div', { key: 'loading', className: 'mc-alert mc-alert--warning' }, 'Mining-Checker Daten werden aktualisiert …') : null, + renderTab(), + ]), + ]); + + function renderTab() { + const currentCoinCurrency = currentMiningCurrency; + const perDayLabel = `${currentCoinCurrency} pro Tag`; + + if (activeTab === 'overview') { + const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null; + const latestPriceSource = latest && latest.effective_price_per_coin !== null && latest.effective_price_per_coin !== undefined + ? latest.effective_price_per_coin + : (latest ? latest.price_per_coin : null); + const latestPrice = latest && latestPriceSource !== null && latestPriceSource !== undefined + ? convertMeasurementMoney(latest, latestPriceSource, reportCurrency) + : null; + const dailyRevenue = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_revenue, reportCurrency) : null; + const dailyProfit = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_profit, reportCurrency) : null; + const dailyCost = latest ? convertMeasurementMoney(latest, latest.effective_daily_cost, reportCurrency) : null; + const breakEvenPrice = latest && latest.break_even_price_per_coin !== null && latest.break_even_price_per_coin !== undefined + ? convertMeasurementMoney(latest, latest.break_even_price_per_coin, reportCurrency) + : null; + const breakEvenRemainingAmount = latest ? convertMeasurementMoney(latest, latest.break_even_remaining_amount, reportCurrency) : null; + const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined + ? Number(latest.break_even_days_overall) + : null; + const investedCapital = latest ? convertMeasurementMoney(latest, latest.cash_invested_capital ?? latest.invested_capital, reportCurrency) : null; + const reinvestedCapital = latest ? convertMeasurementMoney(latest, latest.reinvested_capital, reportCurrency) : null; + const walletValue = latest ? convertMeasurementMoney(latest, latest.wallet_value, reportCurrency) : null; + const totalHoldingsValue = latest ? convertMeasurementMoney(latest, latest.total_holdings_value, reportCurrency) : null; + const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; + const breakEvenEta = latest && latest.break_even_eta_at ? fmtDate(latest.break_even_eta_at) : null; + const walletBalanceCurrentAsset = payload?.summary?.payouts?.wallet_balance_current_asset; + const holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset; + + return h('div', { className: 'mc-stack' }, [ + panel('Berichtswährung', 'Bestimmt die Währung für Kennzahlen im Überblick. Standard kommt aus den Settings, diese Auswahl gilt nur für den aktuellen Besuch.', [ + h('div', { className: 'mc-inline-fields' }, [ + selectField( + 'Aktueller Besuch', + reportCurrency, + selectableCurrencies.map((currency) => currency.code), + (value) => setReportCurrencyOverride(String(value || '').toUpperCase()) + ), + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: resetReportCurrencyOverride, + disabled: !reportCurrencyOverride, + }, 'Standard verwenden'), + ]), + ]), + h('div', { key: 'stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'coins', + label: `${currentCoinCurrency} im Miner`, + value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a', + sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', + }), + h(StatCard, { + key: 'holdings', + label: `Theoretischer Bestand ${currentCoinCurrency}`, + value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a', + sub: payload?.summary?.payouts + ? `Wallet ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency} · Miner ${fmtNumber(latest?.coins_total_visible || latest?.coins_total, 6)} ${currentCoinCurrency}` + : '', + }), + h(StatCard, { + key: 'perday', + label: perDayLabel, + value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a', + sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''), + }), + h(StatCard, { + key: 'value', + label: 'Aktueller Gegenwert', + value: latestValue !== null ? fmtMoney(latestValue, reportCurrency) : 'n/a', + sub: latestPrice !== null + ? `Kurs ${fmtNumber(latestPrice, 6)} ${reportCurrency}${latest && latest.price_is_fallback ? ' · Fallback aus letztem Kurs' : ''}` + : 'Kein umrechenbarer Kurs am letzten Punkt', + }), + h(StatCard, { + key: 'profit', + label: 'Theoretischer Tagesgewinn', + value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a', + sub: dailyCost !== null + ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Walletwert ${walletValue !== null ? fmtMoney(walletValue, reportCurrency) : 'n/a'}` + : 'Kein aktiver Miner fuer diese Waehrung', + }), + h(StatCard, { + key: 'break-even-point', + label: 'Cash-Break-even', + value: breakEvenReached + ? 'Erreicht' + : breakEvenDaysOverall !== null + ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` + : (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'), + sub: investedCapital !== null + ? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}${totalHoldingsValue !== null ? ` · Bestand ${fmtMoney(totalHoldingsValue, reportCurrency)}` : ''}` + : (breakEvenPrice !== null + ? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` + : (investedCapital === null + ? 'Noch keine Miner als Mietbasis hinterlegt' + : 'Keine belastbare Break-even-Basis')), + }), + ]), + h('div', { key: 'charts', className: 'mc-overview-grid' }, [ + panel('Mining-Verlauf', 'Coins total der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })), + panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage.`, h(SimpleChart, { type: 'area', data: overviewCharts.performance })), + panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), + panel('Mining vs. Kurs', `Index-Vergleich der letzten 15 Tage. Mining wird auf ${currentCoinCurrency} pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.`, h(SimpleChart, { + type: 'line', + series: overviewCharts.miningVsPrice, + })), + ]), + panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`, + h('div', { className: 'mc-target-grid' }, + currentTargets.map((target, index) => h('div', { + key: index, + className: 'mc-target-card' + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, target.label), + h(Badge, { key: 'status', tone: target.status === 'reached' ? 'success' : 'info' }, target.status), + ]), + h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [ + h('div', { key: 'amount' }, `Ziel: ${fmtMoney(target.target_amount_fiat, target.currency)}`), + h('div', { key: 'price' }, `Letzter Kurs: ${target.latest_price_for_currency ? fmtNumber(target.latest_price_for_currency, 6) + ' ' + target.currency : 'n/a'}`), + h('div', { key: 'doge' }, `Benoetigte ${currentCoinCurrency}: ${fmtNumber(target.required_doge, 6)}`), + h('div', { key: 'remaining' }, `Rest-${currentCoinCurrency}: ${fmtNumber(target.remaining_doge, 6)}`), + h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`), + ]), + ])) + ) + ), + ]); + } + + if (activeTab === 'measurements') { + return h('div', { className: 'mc-stack' }, [ + panel('Mining-History', 'Die letzten 10 Mining-Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'thead' }, h('tr', null, [ + 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'coins' }, `${fmtNumber(row.coins_total, 6)} ${row.coin_currency || currentCoinCurrency}`), + h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'), + h('td', { key: 'source' }, row.source), + h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)), + h('td', { key: 'trend' }, row.trend_label), + h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'), + h('td', { key: 'action' }, h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteMeasurement(row.id), + disabled: saving, + }, 'Loeschen')), + ])) + ), + ]), + ])), + ]); + } + + if (activeTab === 'upload') { + return h('div', { className: 'mc-stack' }, [ + h('div', { className: 'mc-two-col' }, [ + renderSharedOcrPanel(), + panel('Mining manuell erfassen', 'Direkte Eingabe eines einzelnen Mining-Messpunkts mit serverseitiger Validierung.', h('form', { + className: 'mc-form', + onSubmit: function (event) { + event.preventDefault(); + submitMeasurement(false); + }, + }, [ + displayField('Zeitpunkt', 'Wird beim Speichern automatisch auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + inputField('Coins total', 'number', measurementForm.coins_total, (value) => setMeasurementForm({ ...measurementForm, coins_total: value }), '0.000001'), + inputField('Kurs', 'number', measurementForm.price_per_coin, (value) => setMeasurementForm({ ...measurementForm, price_per_coin: value }), '0.000001'), + selectField('Waehrung', measurementForm.price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setMeasurementForm({ ...measurementForm, price_currency: value })), + textareaField('Notiz', measurementForm.note, (value) => setMeasurementForm({ ...measurementForm, note: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Messpunkt speichern'), + ])), + importHelpOpen ? h('div', { className: 'mc-modal-backdrop', onClick: () => setImportHelpOpen(false) }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, 'Import-Hilfe'), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(false), + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-form' }, [ + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + h('div', { key: 'rules', className: 'mc-display-field' }, [ + h('div', { key: 'rules-label', className: 'mc-field-label' }, 'Hinweise'), + h('div', { key: 'rules-text', className: 'mc-text' }, [ + 'Leere Zeilen sind erlaubt. ', + 'Zeilen mit # oder // am Anfang werden ignoriert. ', + 'Wenn ein Kurs gesetzt ist, muss auch eine Waehrung gesetzt sein. ', + 'Duplikate werden automatisch ignoriert.' + ]), + ]), + h('div', { key: 'example-wrap', className: 'mc-display-field' }, [ + h('div', { key: 'example-label', className: 'mc-field-label' }, 'Beispiel'), + h('pre', { key: 'example', className: 'mc-code-block' }, [ + '21.03.2026 23:48 | 50.988525 | 0.09316 | USD | Screenshot importiert\n', + '22.03.2026 08:10 | 51.402100 | 0.09420 | USD | Morgens\n', + '22.03.2026 14:30 | 51.998700 | | | ohne Kurs' + ]), + ]), + ]), + ]), + ]) : null, + ]), + panel('Mining-Import per Copy & Paste', 'Mehrere historische Mining-Messpunkte auf einmal einfuegen. Doppelte Eintraege werden ignoriert.', h('form', { + className: 'mc-form', + onSubmit: submitMeasurementImport, + }, [ + h('div', { className: 'mc-inline-row' }, [ + h('button', { + key: 'help', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(true), + }, 'Import-Hilfe'), + ]), + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + textareaField('Importdaten', importForm.rows_text, (value) => setImportForm({ ...importForm, rows_text: value })), + selectField('Standard-Waehrung', importForm.default_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setImportForm({ ...importForm, default_currency: value })), + selectField('Import-Quelle', importForm.source, ['manual', 'seed_import'], (value) => setImportForm({ ...importForm, source: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Importiert …' : 'Import ausfuehren'), + ])), + ]); + } + + if (activeTab === 'wallet') { + const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; + const latestWalletAssets = latestWalletSnapshot && latestWalletSnapshot.balances_json && typeof latestWalletSnapshot.balances_json === 'object' + ? Object.entries(latestWalletSnapshot.balances_json) + : []; + + return h('div', { className: 'mc-stack' }, [ + panel('Wallet-Bestaende', 'Der letzte Wallet-Snapshot zeigt alle erkannten Assets separat.', latestWalletAssets.length + ? h('div', { className: 'mc-asset-grid' }, latestWalletAssets.map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return h('div', { key: code, className: 'mc-display-field mc-asset-card' }, [ + h('div', { key: 'code', className: 'mc-field-label' }, code), + h('div', { key: 'balance', className: 'mc-asset-balance' }, `${fmtNumber(balance, 8)} ${code}`), + h('div', { key: 'price', className: 'mc-text' }, priceAmount !== null && priceAmount !== undefined + ? `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim() + : 'Kein Screenshot-Kurs erkannt'), + ]); + })) + : h('div', { className: 'mc-empty' }, 'Noch keine Wallet-Assets erkannt.')), + panel('Wallet-Historie', 'Die letzten 10 Wallet-Uploads mit allen aus dem Screenshot gelesenen Assets.', h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'wallet-table', className: 'mc-table' }, [ + h('thead', { key: 'thead' }, h('tr', null, [ + 'Zeit', 'Quelle', 'Assets' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + currentWalletSnapshots.length + ? currentWalletSnapshots.slice(0, 10).map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'source' }, row.source || 'manual'), + h('td', { key: 'assets' }, h('div', { className: 'mc-asset-list' }, Object.entries(row.balances_json || {}).map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return h('div', { key: code, className: 'mc-asset-row' }, [ + h('strong', { key: 'code' }, code), + h('span', { key: 'balance' }, `${fmtNumber(balance, 8)} ${code}`), + priceAmount !== null && priceAmount !== undefined + ? h('span', { key: 'price', className: 'mc-text' }, `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim()) + : null, + ]); + }))), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 3 }, 'Noch keine Wallet-Snapshots gespeichert.'))] + ), + ]), + ])), + ]); + } + + if (activeTab === 'dashboards') { + return h('div', { className: 'mc-main-grid' }, [ + panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', { + className: 'mc-form', + onSubmit: submitDashboard, + }, [ + inputField('Name', 'text', dashboardForm.name, (value) => setDashboardForm({ ...dashboardForm, name: value })), + selectField('Chart-Typ', dashboardForm.chart_type, ['line', 'bar', 'area', 'table'], (value) => setDashboardForm({ ...dashboardForm, chart_type: value })), + selectField('X-Feld', dashboardForm.x_field, ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'], (value) => setDashboardForm({ ...dashboardForm, x_field: value })), + selectField('Y-Feld', dashboardForm.y_field, ['coins_total', 'price_per_coin', 'growth_since_baseline', 'doge_per_hour_since_baseline', 'doge_per_day_since_baseline', 'doge_per_hour_interval', 'doge_per_day_interval', 'current_value', 'theoretical_daily_revenue', 'theoretical_daily_profit'], (value) => setDashboardForm({ ...dashboardForm, y_field: value })), + selectField('Aggregation', dashboardForm.aggregation, ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], (value) => setDashboardForm({ ...dashboardForm, aggregation: value })), + selectField('Filter Quelle', dashboardForm.filters.source, ['', 'manual', 'image_ocr', 'seed_import'], (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, source: value } })), + selectField('Filter Waehrung', dashboardForm.filters.currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, currency: value } })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Dashboard speichern'), + ])), + h('div', { className: 'mc-stack' }, currentDashboards.map((definition) => h(DashboardCard, { + key: definition.id, + definition, + data: dashboardData[definition.id], + loading: !dashboardData[definition.id], + }))), + ]); + } + + if (activeTab === 'mining') { + const scenarioCurrency = selectedMinerScenario?.scenario_currency || reportCurrency; + const scenarioCurrentDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_current_daily_profit, reportCurrency) : null; + const scenarioDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit, reportCurrency) : null; + const scenarioDailyProfitDelta = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit_delta, reportCurrency) : null; + const scenarioInvestedCapital = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_invested_capital, reportCurrency) : null; + const scenarioOfferCost = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_offer_cost, reportCurrency) : null; + const scenarioBreakEvenRemaining = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_break_even_remaining_amount, reportCurrency) : null; + return h('div', { className: 'mc-stack' }, [ + panel('Aktive Miner', 'Alle bereits gemieteten oder manuell eingetragenen Miner in einer gemeinsamen Liste.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-server', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setCostPlanModalOpen(true), + }, 'Miner eintragen'), + h('button', { + key: 'rent-miner', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, + disabled: !availableMinerOffers.length, + }, 'Neuen Miner mieten'), + ]), + h('div', { key: 'list', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Start', 'Laufzeit', 'Auto', 'Kosten', 'Waehrung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + activeMinerRows.length + ? activeMinerRows.map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'label' }, [ + h('div', { key: 'main' }, row.label), + h('div', { key: 'type', className: 'mc-kicker' }, row.type_label), + ]), + h('td', { key: 'start' }, fmtDateTime(row.starts_at)), + h('td', { key: 'runtime' }, [ + h('div', { key: 'months' }, `${row.runtime_months} Monate`), + h('div', { key: 'hash', className: 'mc-kicker' }, row.hashrate_text), + ]), + h('td', { key: 'renew' }, row.auto_renew ? 'ja' : 'nein'), + h('td', { key: 'cost' }, [ + h('div', { key: 'effective' }, fmtNumber(row.effective_amount, 6)), + row.base_amount !== null && row.base_amount !== undefined && row.base_currency + ? h('div', { key: 'base', className: 'mc-kicker' }, `Basis ${fmtNumber(row.base_amount, 6)} ${row.base_currency}`) + : null, + ]), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, row.effective_currency), + row.payment_type ? h('div', { key: 'currency-mode', className: 'mc-kicker' }, row.payment_type === 'crypto' ? 'Zahlung Krypto' : 'Zahlung FIAT') : null, + ]), + h('td', { key: 'active' }, row.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + row.can_toggle_auto_renew + ? h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => togglePurchasedMinerAutoRenew(row), + disabled: saving, + }, row.auto_renew ? 'Verlaengerung aus' : 'Verlaengerung an') + : '—' + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Miner hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Auszahlungen', 'Auszahlungen reduzieren den sichtbaren Coin-Bestand, bleiben aber in der Gesamtleistung erhalten.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-payout', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => { + setPayoutForm((previous) => ({ + payout_at: previous.payout_at || nowDateTimeLocalValue(), + coins_amount: previous.coins_amount || '', + payout_currency: currentCoinCurrency, + note: previous.note || '', + })); + setPayoutModalOpen(true); + }, + }, 'Auszahlung erfassen'), + ]), + h('div', { key: 'payout-list', className: 'mc-table-shell' }, [ + h('table', { key: 'payout-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentPayouts.length + ? currentPayouts.slice().reverse().map((payout) => h('tr', { key: payout.id }, [ + h('td', { key: 'time' }, fmtDate(payout.payout_at)), + h('td', { key: 'coins' }, fmtNumber(payout.coins_amount, 6)), + h('td', { key: 'currency' }, payout.payout_currency), + h('td', { key: 'note' }, payout.note || '—'), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 4 }, 'Noch keine Auszahlungen hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Miner-Angebote', currentWalletMiningBalance !== null + ? `Hier werden nur Basis-Miner gepflegt. Krypto-Angebote werden gegen deinen aktuellen Wallet-Bestand (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency}) gefiltert.` + : 'Hier werden nur Basis-Miner gepflegt. Alle vorgegebenen Geschwindigkeiten und Preise werden daraus automatisch abgeleitet.', + [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-offer', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setMinerOfferModalOpen(true), + }, 'Basis-Angebot anlegen'), + ]), + h('div', { key: 'filters', className: 'mc-filter-grid' }, [ + inputField(`Min. Geschwindigkeit (${minerOfferFilters.speed_unit === 'kh' ? 'kH/s' : 'MH/s'})`, 'number', minerOfferFilters.speed_min, (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_min: value }), '0.0001'), + selectField('Geschwindigkeitseinheit', minerOfferFilters.speed_unit, [ + { value: 'auto', label: 'MH/s' }, + { value: 'kh', label: 'kH/s' }, + ], (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_unit: value || 'auto' })), + inputField(`Max. Basispreis (${reportCurrency})`, 'number', minerOfferFilters.price_max, (value) => setMinerOfferFilters({ ...minerOfferFilters, price_max: value }), '0.0001'), + selectField('Laufzeit', minerOfferFilters.runtime_months, [{ value: '', label: 'Alle Laufzeiten' }].concat(Array.from(new Set(availableMinerOffers.map((offer) => String(offer.runtime_months || '')).filter(Boolean))).sort((a, b) => Number(a) - Number(b)).map((value) => ({ + value, + label: `${value} Monate`, + }))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })), + ]), + h('div', { key: 'offers-table', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Hashrate', 'Preis', 'Erwartet/Tag', 'Break-even', 'Empfehlung', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + filteredMinerOffers.length + ? filteredMinerOffers.map((offer) => h('tr', { key: offer.id }, [ + h('td', { key: 'label' }, offer.label), + h('td', { key: 'hashrate' }, formatAdaptiveSpeed(offer.offer_hashrate_mh)), + h('td', { key: 'price' }, [ + h('div', { key: 'price-main' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen'), + ]), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'price-base' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Gegenwert'), + ]) + : null, + ]), + h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} ${currentCoinCurrency}` : 'n/a'), + h('td', { key: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'), + h('td', { key: 'rec' }, [ + h('div', { key: 'rec-main' }, offer.recommendation), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'rec-ref', className: 'mc-kicker' }, `Basis ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`) + : null, + offer.payment_type ? h('div', { key: 'paytype', className: 'mc-kicker' }, offer.payment_type === 'crypto' ? `Zahlung in Krypto (${currentSettings.crypto_currency || 'DOGE'})` : `Zahlung in FIAT (${offer.base_price_currency || 'EUR'})`) : null, + h('div', { key: 'renew', className: 'mc-kicker' }, offer.auto_renew ? 'Automatische Verlängerung' : 'Laeuft aus'), + ]), + h('td', { key: 'action' }, [ + h('button', { + key: 'scenario', + type: 'button', + className: cx('mc-button', String(selectedMinerScenario?.id) === String(offer.id) ? 'mc-button--secondary' : 'mc-button--ghost'), + onClick: () => setSelectedMinerScenarioId(offer.id), + }, 'Szenario'), + h('button', { + key: 'target', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setTargetForm({ + label: offer.label, + target_amount_fiat: String(offer.base_price_amount ?? offer.effective_price_amount ?? ''), + currency: offer.base_price_currency || offer.effective_price_currency || 'EUR', + miner_offer_id: '', + is_active: true, + sort_order: 0, + }); + setTargetModalOpen(true); + }, + }, 'Als Ziel'), + h('button', { + key: 'buy', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setPurchaseMinerForm({ + offer_id: String(offer.id), + base_offer_id: String(offer.base_offer_id || offer.id || ''), + purchased_at: nowDateTimeLocalValue(), + label: String(offer.label || ''), + mining_speed_value: String(offer.mining_speed_value || ''), + mining_speed_unit: String(offer.mining_speed_unit || ''), + bonus_percent: offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', + total_cost_amount: offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer.effective_price_currency || offer.base_price_currency || 'USD', + reference_price_amount: offer.base_price_amount !== null && offer.base_price_amount !== undefined ? String(offer.base_price_amount) : '', + reference_price_currency: offer.base_price_currency || '', + auto_renew: !!offer.auto_renew, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, + }, 'Mieten'), + ]), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 7 }, 'Keine Angebote passen auf die gesetzten Filter.'))] + ), + ]), + ]), + selectedMinerScenario ? panel( + `Szenario: ${selectedMinerScenario.label}`, + 'Zeigt, wie sich Kennzahlen veraendern wuerden, wenn dieser Miner jetzt zusaetzlich gemietet wird.', + [ + h('div', { key: 'scenario-stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'scenario-profit', + label: 'Tagesgewinn Neu', + value: scenarioDailyProfit !== null ? fmtMoney(scenarioDailyProfit, reportCurrency) : 'n/a', + sub: scenarioDailyProfitDelta !== null + ? `Aenderung pro Tag ${fmtMoney(scenarioDailyProfitDelta, reportCurrency)}` + : 'Keine belastbare Gewinnprognose', + }), + h(StatCard, { + key: 'scenario-doge', + label: `${currentCoinCurrency} pro Tag Neu`, + value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a', + sub: selectedMinerScenario.scenario_current_doge_per_day !== null + ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}` + : `Keine aktuelle ${currentCoinCurrency}/Tag-Basis`, + }), + h(StatCard, { + key: 'scenario-break-even', + label: 'Break-even Neu', + value: selectedMinerScenario.scenario_break_even_days !== null + ? `${fmtNumber(selectedMinerScenario.scenario_break_even_days, 2)} Tage` + : 'n/a', + sub: selectedMinerScenario.scenario_break_even_date + ? `Theoretisch ${fmtDate(selectedMinerScenario.scenario_break_even_date)}` + : 'Kein belastbares Break-even-Datum', + }), + h(StatCard, { + key: 'scenario-capital', + label: 'Kosten inkl. Miete', + value: scenarioInvestedCapital !== null ? fmtMoney(scenarioInvestedCapital, reportCurrency) : 'n/a', + sub: scenarioOfferCost !== null + ? `Neue Miete ${fmtMoney(scenarioOfferCost, reportCurrency)}` + : `Mietpreis in ${scenarioCurrency}`, + }), + h(StatCard, { + key: 'scenario-two-year', + label: '2 Jahre Ergebnis Neu', + value: selectedMinerScenario.scenario_two_year_profit !== null + ? fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit, reportCurrency), reportCurrency) + : 'n/a', + sub: selectedMinerScenario.scenario_two_year_profit_delta !== null + ? `Aenderung ggü. heute ${fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit_delta, reportCurrency), reportCurrency)}` + : 'Laufzeit und Verlaengerung beruecksichtigt', + }), + ]), + h('div', { key: 'scenario-meta', className: 'mc-mini-grid' }, [ + h('div', { key: 'hashrate', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Hashrate'), + h('div', { key: 'value' }, selectedMinerScenario.scenario_hashrate_mh !== null ? `${fmtNumber(selectedMinerScenario.scenario_hashrate_mh, 4)} MH/s` : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, selectedMinerScenario.scenario_current_hashrate_mh !== null ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_hashrate_mh, 4)} MH/s` : 'Aktuell n/a'), + ]), + h('div', { key: 'remaining', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Offen bis Break-even'), + h('div', { key: 'value' }, scenarioBreakEvenRemaining !== null ? fmtMoney(scenarioBreakEvenRemaining, reportCurrency) : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, scenarioCurrentDailyProfit !== null ? `Aktueller Tagesgewinn ${fmtMoney(scenarioCurrentDailyProfit, reportCurrency)}` : 'Aktueller Tagesgewinn n/a'), + ]), + ]), + ] + ) : h('div', { key: 'scenario-empty', className: 'mc-empty' }, 'Waehle bei einem Angebot "Szenario", um die Auswirkung hier anzuzeigen.'), + ]), + panel('Ziele', 'Ziele koennen direkt oder aus einem Miner-Angebot heraus angelegt werden.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-target', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setTargetModalOpen(true), + }, 'Ziel anlegen'), + ]), + h('div', { key: 'target-list', className: 'mc-table-shell' }, [ + h('table', { key: 'target-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Betrag', 'Waehrung', 'Resttage', 'Ziel erreicht ca.', 'Sortierung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentTargets.length + ? currentTargets.map((target) => h('tr', { key: target.id || target.label }, [ + h('td', { key: 'label' }, target.label), + h('td', { key: 'amount' }, fmtNumber(target.effective_target_amount_fiat ?? target.target_amount_fiat, 2)), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, target.effective_currency || target.currency), + target.linked_offer_label ? h('div', { key: 'currency-offer', className: 'mc-kicker' }, `Angebot ${target.linked_offer_label}`) : null, + ]), + h('td', { key: 'days' }, target.remaining_days !== null && target.remaining_days !== undefined ? fmtNumber(target.remaining_days, 2) : 'n/a'), + h('td', { key: 'eta' }, target.target_eta_at ? fmtDateTime(target.target_eta_at) : 'n/a'), + h('td', { key: 'sort' }, String(target.sort_order ?? 0)), + h('td', { key: 'active' }, target.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteTarget(target), + disabled: saving, + }, 'Loeschen') + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Ziele hinterlegt.'))] + ), + ]), + ]), + ]), + costPlanModalOpen ? renderModal('Miner eintragen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitCostPlan }, [ + inputField('Label', 'text', costPlanForm.label, (value) => setCostPlanForm({ ...costPlanForm, label: value })), + inputField('Startdatum', 'datetime-local', costPlanForm.starts_at, (value) => setCostPlanForm({ ...costPlanForm, starts_at: value })), + inputField('Laufzeit in Monaten', 'number', String(costPlanForm.runtime_months), (value) => setCostPlanForm({ ...costPlanForm, runtime_months: Number(value) || 0 })), + inputField('Mining-Geschwindigkeit', 'number', costPlanForm.mining_speed_value, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', costPlanForm.mining_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_unit: value })), + inputField('Bonus-Hashrate in %', 'number', costPlanForm.bonus_percent, (value) => setCostPlanForm({ ...costPlanForm, bonus_percent: value }), '0.01'), + inputField(`Basispreis in ${settingsForm.report_currency || 'EUR'}`, 'number', costPlanForm.base_price_amount, (value) => setCostPlanForm({ ...costPlanForm, base_price_amount: value }), '0.000001'), + selectField('Zahlungsart', costPlanForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setCostPlanForm({ ...costPlanForm, payment_type: value })), + textareaField('Notiz', costPlanForm.note, (value) => setCostPlanForm({ ...costPlanForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.auto_renew, onChange: (event) => setCostPlanForm({ ...costPlanForm, auto_renew: event.target.checked }) }), + 'Automatisch verlaengernd', + ]), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.is_active, onChange: (event) => setCostPlanForm({ ...costPlanForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setCostPlanModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner speichern'), + ]), + ]), + ], () => setCostPlanModalOpen(false)) : null, + payoutModalOpen ? renderModal('Auszahlung erfassen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [ + inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })), + inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'), + selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })), + textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Auszahlung speichern'), + ]), + ]), + ], () => setPayoutModalOpen(false)) : null, + minerOfferModalOpen ? renderModal('Basis-Miner-Angebot anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ + inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), + inputField('Laufzeit in Monaten', 'number', minerOfferForm.runtime_months, (value) => setMinerOfferForm({ ...minerOfferForm, runtime_months: value })), + displayField('Basis-Geschwindigkeit', baseOfferSpeedLabel(minerOfferForm.payment_type)), + inputField('Bonus-Hashrate in %', 'number', minerOfferForm.bonus_percent, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_percent: value }), '0.01'), + inputField(minerOfferForm.payment_type === 'crypto' ? 'Basispreis in USD' : 'Basispreis', 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), + selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ + ...minerOfferForm, + payment_type: value, + base_price_currency: value === 'crypto' + ? 'USD' + : (selectableFiatCurrencies[0]?.code || 'USD'), + })), + minerOfferForm.payment_type === 'crypto' + ? displayField('Kostenwaehrung', 'USD') + : selectField('Basiswährung', minerOfferForm.base_price_currency, selectableFiatCurrencies.map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.auto_renew, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', minerOfferForm.note, (value) => setMinerOfferForm({ ...minerOfferForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.is_active, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, is_active: event.target.checked }) }), + 'Als verfuegbar markieren', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setMinerOfferModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Angebot speichern'), + ]), + ]), + ], () => setMinerOfferModalOpen(false)) : null, + purchaseMinerModalOpen ? renderModal('Neuen Miner mieten', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPurchaseMiner }, [ + selectField('Angebot', purchaseMinerForm.offer_id, [{ value: '', label: 'Bitte waehlen' }].concat(availableMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`, + }))), (value) => { + const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); + setPurchaseMinerForm({ + offer_id: value, + base_offer_id: offer ? String(offer.base_offer_id || offer.id || '') : '', + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), + label: offer ? String(offer.label || '') : '', + mining_speed_value: offer && offer.mining_speed_value !== null && offer.mining_speed_value !== undefined ? String(offer.mining_speed_value) : '', + mining_speed_unit: offer?.mining_speed_unit || '', + bonus_percent: offer && offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', + total_cost_amount: offer && offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer?.effective_price_currency || offer?.base_price_currency || 'USD', + reference_price_amount: offer && offer.reference_price_amount !== null && offer.reference_price_amount !== undefined ? String(offer.reference_price_amount) : '', + reference_price_currency: offer?.reference_price_currency || '', + auto_renew: !!offer?.auto_renew, + note: purchaseMinerForm.note || '', + }); + }), + inputField('Mietdatum/-zeit', 'datetime-local', purchaseMinerForm.purchased_at, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, purchased_at: value })), + inputField('Label', 'text', purchaseMinerForm.label, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, label: value })), + inputField('Mining-Geschwindigkeit', 'number', purchaseMinerForm.mining_speed_value, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', purchaseMinerForm.mining_speed_unit, speedUnits, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, mining_speed_unit: value })), + inputField('Bonus-Hashrate in %', 'number', purchaseMinerForm.bonus_percent, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, bonus_percent: value }), '0.01'), + inputField('Exakter Mietpreis', 'number', purchaseMinerForm.total_cost_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, total_cost_amount: value }), '0.000001'), + selectField('Mietwährung', purchaseMinerForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, currency: value })), + inputField('Referenzpreis', 'number', purchaseMinerForm.reference_price_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_amount: value }), '0.000001'), + selectField('Referenzwährung', purchaseMinerForm.reference_price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_currency: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!purchaseMinerForm.auto_renew, onChange: (event) => setPurchaseMinerForm({ ...purchaseMinerForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', purchaseMinerForm.note, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPurchaseMinerModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner mieten'), + ]), + ]), + ], () => setPurchaseMinerModalOpen(false)) : null, + targetModalOpen ? renderModal('Ziel anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitTarget }, [ + inputField('Label', 'text', targetForm.label, (value) => setTargetForm({ ...targetForm, label: value })), + selectField('Angebots-Verknuepfung', targetForm.miner_offer_id || '', [{ value: '', label: 'Kein verknuepftes Angebot' }].concat(currentMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`, + }))), (value) => { + const offer = currentMinerOffers.find((item) => String(item.id) === String(value)); + setTargetForm({ + ...targetForm, + miner_offer_id: value, + label: targetForm.label || offer?.label || '', + target_amount_fiat: offer ? String(offer.base_price_amount ?? offer.effective_price_amount ?? '') : targetForm.target_amount_fiat, + currency: offer?.base_price_currency || offer?.effective_price_currency || targetForm.currency, + }); + }), + inputField('Betrag', 'number', targetForm.target_amount_fiat, (value) => setTargetForm({ ...targetForm, target_amount_fiat: value }), '0.01'), + selectField('Waehrung', targetForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setTargetForm({ ...targetForm, currency: value })), + inputField('Sortierung', 'number', String(targetForm.sort_order), (value) => setTargetForm({ ...targetForm, sort_order: Number(value) || 0 })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!targetForm.is_active, onChange: (event) => setTargetForm({ ...targetForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setTargetModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Ziel speichern'), + ]), + ]), + ], () => setTargetModalOpen(false)) : null, + ]); + } + + return h('div', { className: 'mc-two-col' }, [ + h('div', { className: 'mc-stack' }, [ + panel('Initialisierung', 'Prueft den Tabellenstatus und kann das Mining-Checker Schema neu anlegen. Reset loescht bestehende miningcheck_ Tabellen inklusive Daten.', [ + h('div', { key: 'status', className: 'mc-form' }, [ + displayField('Status', schemaStatus.all_present ? 'Schema vollstaendig vorhanden' : 'Schema unvollstaendig'), + displayField('Vorhandene Tabellen', `${schemaStatus.present_count}/${schemaStatus.required_tables.length}`), + displayField('Fehlende Tabellen', schemaStatus.missing_tables.length ? schemaStatus.missing_tables.join(', ') : 'keine'), + displayField('Ausstehende Upgrades', schemaStatus.pending_upgrades.length ? schemaStatus.pending_upgrades.join(', ') : 'keine'), + ]), + h('form', { key: 'form', className: 'mc-form', onSubmit: initializeModule }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'drop-existing', + type: 'checkbox', + checked: !!initForm.drop_existing, + onChange: (event) => setInitForm({ drop_existing: event.target.checked }), + }), + 'Bestehende Mining-Checker Tabellen inkl. Daten loeschen und neu anlegen', + ]), + h('button', { + type: 'submit', + className: initForm.drop_existing ? 'mc-button mc-button--danger' : 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Initialisiert …' : (initForm.drop_existing ? 'Reset + Schema neu anlegen' : 'Schema initialisieren')), + ]), + h('button', { + key: 'upgrade', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: upgradeDatabaseSchema, + disabled: saving, + }, saving ? 'Upgradet …' : 'DB auf neueste Version upgraden'), + h('button', { + key: 'old-data-import', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: importOldData, + disabled: saving, + }, saving ? 'Importiert …' : 'Alte Daten importieren'), + h('button', { + key: 'legacy-fx-migrate', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: migrateLegacyFxData, + disabled: saving, + }, saving ? 'Migriert …' : 'Legacy FX zu fx-rates migrieren'), + h('div', { key: 'sql-import', className: 'mc-form' }, [ + h('label', { className: 'mc-field' }, [ + h('span', { className: 'mc-field-label' }, 'SQL-Datei importieren'), + h('input', { + type: 'file', + accept: '.sql,text/sql,application/sql', + onChange: (event) => setSqlImportFile(event.target.files && event.target.files[0] ? event.target.files[0] : null), + }), + ]), + h('div', { className: 'mc-text' }, + sqlImportFile + ? `Ausgewaehlt: ${sqlImportFile.name}` + : 'Fuehrt die ausgewaehlte SQL-Datei direkt in der aktuellen Projekt-Datenbank aus. Bestehende Daten werden dabei nicht automatisch geloescht.' + ), + h('button', { + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: importSqlFile, + disabled: saving || !sqlImportFile, + }, saving ? 'Importiert …' : 'SQL-Datei einspielen'), + ]), + ]), + panel('Datenbank-Test', 'Prueft, ob das Modul die Projekt-Datenbank erreichen und eine einfache Anfrage ausfuehren kann.', [ + dbCheck + ? h('div', { key: 'dbcheck-result', className: 'mc-form' }, [ + displayField('Status', dbCheck.ok ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'), + displayField('Driver', dbCheck.driver || 'n/a'), + displayField('Datenbank', dbCheck.database || 'n/a'), + displayField('Tabellenpraefix', dbCheck.table_prefix || 'n/a'), + ]) + : h('div', { key: 'dbcheck-empty', className: 'mc-empty' }, 'Noch kein Verbindungstest ausgefuehrt.'), + h('button', { + key: 'dbcheck-button', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: testDatabaseConnection, + disabled: saving, + }, saving ? 'Prueft …' : 'DB-Verbindung testen'), + ]), + ]), + h('div', { className: 'mc-stack' }, [ + panel('Basis-Settings', 'Baseline bleibt als Referenzwert mit Datum und Uhrzeit bestehen.', h('form', { + className: 'mc-form', + onSubmit: submitSettings, + }, [ + inputField('Baseline Zeitpunkt', 'datetime-local', toDateTimeLocalValue(settingsForm.baseline_measured_at), (value) => setSettingsForm({ ...settingsForm, baseline_measured_at: value })), + inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'), + selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })), + selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Settings speichern'), + ])), + panel('Modulrechte', 'Steuert, wer den Mining-Checker auf der Startseite sieht und direkt aufrufen darf.', h('form', { + className: 'mc-form', + onSubmit: submitModuleAuth, + }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'required', + type: 'checkbox', + checked: !!moduleAuthForm.required, + onChange: (event) => setModuleAuthForm({ ...moduleAuthForm, required: event.target.checked }), + }), + 'Login fuer dieses Modul erforderlich', + ]), + inputField('Erlaubte Benutzer / Subs', 'text', moduleAuthForm.users, (value) => setModuleAuthForm({ ...moduleAuthForm, users: value })), + inputField('Erlaubte Gruppen', 'text', moduleAuthForm.groups, (value) => setModuleAuthForm({ ...moduleAuthForm, groups: value })), + h('div', { className: 'mc-text' }, + 'Mehrere Werte mit Komma trennen. Benutzerfeld akzeptiert Keycloak-Sub, Benutzername oder E-Mail. Leer bedeutet: jeder eingeloggte Benutzer darf das Modul nutzen.'), + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Modulrechte speichern'), + ])), + ]), + ]); + } + + function panel(title, subtitle, content) { + return h('section', { className: 'mc-panel' }, [ + h(SectionTitle, { key: 'title', title, subtitle }), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]); + } + + function fieldWrapper(label, child) { + return h('label', { className: 'mc-field' }, [ + h('span', { key: 'label', className: 'mc-field-label' }, label), + child, + ]); + } + + function inputField(label, type, value, onChange, step) { + return fieldWrapper(label, h('input', { + className: 'mc-input', + type, + step: step || undefined, + value: value, + onChange: (event) => onChange(event.target.value), + })); + } + + function selectField(label, value, options, onChange) { + return fieldWrapper(label, h('select', { + className: 'mc-select', + value, + onChange: (event) => onChange(event.target.value), + }, options.map((option) => { + const normalized = option && typeof option === 'object' + ? option + : { value: option, label: option || 'alle' }; + return h('option', { + key: normalized.value || 'empty', + value: normalized.value, + }, normalized.label || 'alle'); + }))); + } + + function textareaField(label, value, onChange) { + return fieldWrapper(label, h('textarea', { + className: 'mc-textarea', + value, + onChange: (event) => onChange(event.target.value), + })); + } + + function fileField(label, onChange) { + return fieldWrapper(label, h('input', { + className: 'mc-file', + type: 'file', + accept: 'image/png,image/jpeg,image/webp', + onChange: (event) => onChange(event.target.files && event.target.files[0] ? event.target.files[0] : null), + })); + } + + function displayField(label, value) { + return h('div', { className: 'mc-display-field' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, label), + h('div', { key: 'value', className: 'mc-text' }, value || 'n/a'), + ]); + } + + function renderModal(title, content, onClose) { + return h('div', { + className: 'mc-modal-backdrop', + onClick: onClose, + }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-section-head' }, [ + h('div', { key: 'title-wrap' }, [ + h('h3', { key: 'title' }, title), + ]), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: onClose, + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]), + ]); + } + + function formatSpeed(value, unit, label) { + if (value === null || value === undefined || value === '' || !unit) { + return ''; + } + + return `${label ? label + ' ' : ''}${fmtNumber(value, 4)} ${unit}`; + } + + function baseOfferSpeedLabel(paymentType) { + return String(paymentType || 'fiat') === 'crypto' ? '75 kH/s' : '50 kH/s'; + } + + function formatHashrateWithBonus(speedValue, speedUnit, bonusValue, bonusUnit) { + const parts = [ + formatSpeed(speedValue, speedUnit, 'Basis'), + Number(bonusValue) > 0 ? formatSpeed(bonusValue, bonusUnit, 'Bonus') : '', + ].filter(Boolean); + return parts.length ? parts.join(' · ') : 'n/a'; + } + + function formatAdaptiveSpeed(valueMh) { + const numericValue = Number(valueMh); + if (!Number.isFinite(numericValue)) { + return 'n/a'; + } + + if (numericValue > 0 && numericValue < 1) { + return `${fmtNumber(numericValue * 1000, 2)} kH/s`; + } + + return `${fmtNumber(numericValue, 4)} MH/s`; + } + } + + ReactDOM.createRoot(root).render(h(App)); +})(); diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index d6d20068..78b95357 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -114,7 +114,7 @@ if (payloadNode) { return `
- +

${title} wird geladen.

Falls die Einbettung leer bleibt, direkt oeffnen: ${src}

@@ -536,6 +536,14 @@ if (payloadNode) { node.addEventListener('mousedown', () => focusWindow(record.id)); + const frame = node.querySelector('.window-app-frame'); + const frameFallback = node.querySelector('.window-embed-fallback'); + if (frame && frameFallback) { + frame.addEventListener('load', () => { + frameFallback.remove(); + }, { once: true }); + } + node.querySelectorAll('.window-action').forEach((action) => { action.addEventListener('click', (event) => { event.stopPropagation(); diff --git a/src/MiningChecker/LegacyModuleStore.php b/src/MiningChecker/LegacyModuleStore.php new file mode 100644 index 00000000..e7fcae6d --- /dev/null +++ b/src/MiningChecker/LegacyModuleStore.php @@ -0,0 +1,191 @@ + + */ + public function loadProject(string $projectKey): array + { + $state = $this->loadAll(); + $projects = is_array($state['projects'] ?? null) ? $state['projects'] : []; + + if (!isset($projects[$projectKey]) || !is_array($projects[$projectKey])) { + $projects[$projectKey] = $this->defaultProjectState($projectKey); + $state['projects'] = $projects; + $this->saveAll($state); + } + + return array_replace_recursive($this->defaultProjectState($projectKey), $projects[$projectKey]); + } + + /** + * @param array $projectState + */ + public function saveProject(string $projectKey, array $projectState): void + { + $state = $this->loadAll(); + $projects = is_array($state['projects'] ?? null) ? $state['projects'] : []; + $projects[$projectKey] = array_replace_recursive($this->defaultProjectState($projectKey), $projectState); + $state['projects'] = $projects; + $this->saveAll($state); + } + + /** + * @return array + */ + public function loadAuth(): array + { + $state = $this->loadAll(); + $auth = is_array($state['module_auth'] ?? null) ? $state['module_auth'] : []; + + return array_replace_recursive([ + 'required' => true, + 'users' => [], + 'groups' => [], + ], $auth); + } + + /** + * @param array $auth + */ + public function saveAuth(array $auth): void + { + $state = $this->loadAll(); + $state['module_auth'] = [ + 'required' => (bool) ($auth['required'] ?? true), + 'users' => array_values(array_filter(array_map('strval', (array) ($auth['users'] ?? [])))), + 'groups' => array_values(array_filter(array_map('strval', (array) ($auth['groups'] ?? [])))), + ]; + $this->saveAll($state); + } + + public function nextId(string $projectKey, string $bucket): int + { + $project = $this->loadProject($projectKey); + $counters = is_array($project['_counters'] ?? null) ? $project['_counters'] : []; + $next = (int) ($counters[$bucket] ?? 0) + 1; + $counters[$bucket] = $next; + $project['_counters'] = $counters; + $this->saveProject($projectKey, $project); + + return $next; + } + + /** + * @return array + */ + private function loadAll(): array + { + $path = $this->path(); + + if (!is_file($path)) { + return [ + 'module_auth' => [ + 'required' => true, + 'users' => [], + 'groups' => [], + ], + 'projects' => [], + ]; + } + + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + return [ + 'module_auth' => [ + 'required' => true, + 'users' => [], + 'groups' => [], + ], + 'projects' => [], + ]; + } + + $data = json_decode($raw, true); + + return is_array($data) ? $data : [ + 'module_auth' => [ + 'required' => true, + 'users' => [], + 'groups' => [], + ], + 'projects' => [], + ]; + } + + /** + * @param array $state + */ + private function saveAll(array $state): void + { + $directory = dirname($this->path()); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Mining-Checker Speicherverzeichnis konnte nicht erstellt werden.'); + } + + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht serialisiert werden.'); + } + + if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht geschrieben werden.'); + } + } + + private function path(): string + { + $safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest'; + + return $this->projectRoot . '/data/mining-checker/users/' . $safeScope . '.json'; + } + + /** + * @return array + */ + private function defaultProjectState(string $projectKey): array + { + return [ + '_counters' => [], + 'project' => [ + 'project_key' => $projectKey, + 'project_name' => strtoupper(str_replace('-', ' ', $projectKey)), + ], + 'settings' => [ + 'baseline_measured_at' => gmdate('Y-m-d H:i:s'), + 'baseline_coins_total' => 0, + 'daily_cost_amount' => 0, + 'daily_cost_currency' => 'EUR', + 'report_currency' => 'EUR', + 'crypto_currency' => 'DOGE', + 'display_timezone' => 'Europe/Berlin', + 'fx_max_age_hours' => 3, + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + ], + 'measurements' => [], + 'targets' => [], + 'dashboards' => [], + 'wallet_snapshots' => [], + 'cost_plans' => [], + 'payouts' => [], + 'miner_offers' => [], + 'purchased_miners' => [], + 'fx_history' => [], + ]; + } +}