(function () { const root = document.getElementById('mining-checker-app'); if (!root || !window.React || !window.ReactDOM) { return; } const h = React.createElement; const { useEffect, useMemo, useState } = React; const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; const fxProvider = root.dataset.fxProvider || 'currencyapi'; const fxBaseUrl = root.dataset.fxUrl || 'https://currencyapi.net'; const fxCurrenciesUrl = root.dataset.fxCurrenciesUrl || fxBaseUrl; const fxApiKeyMask = root.dataset.fxApiKeyMask || ''; 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 = (() => { try { return window.localStorage.getItem('mining-checker-debug-enabled') === '1'; } catch (error) { return false; } })(); const initialDebugConsoleOpen = (() => { try { return window.localStorage.getItem('mining-checker-debug-console-open') === '1'; } catch (error) { return false; } })(); 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 initialDebugView = (() => { try { const value = window.localStorage.getItem('mining-checker-debug-view'); return value === 'text' ? 'text' : 'structured'; } catch (error) { return 'structured'; } })(); const debugBus = window.__miningCheckerDebugBus || { enabled: initialDebugMode, listener: null, sequence: 0, }; window.__miningCheckerDebugBus = debugBus; 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 persistDebugCookie(enabled) { const value = enabled ? '1' : '0'; document.cookie = `mining_checker_debug=${value}; path=/; max-age=31536000; samesite=lax`; } 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; } 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 : {}), }); }); } function buildExternalFxUrl(base) { if (fxProvider === 'currencyapi') { const params = new URLSearchParams({ base: String(base || 'USD').toUpperCase(), output: 'json', }); if (fxApiKeyMask) { params.set('key', fxApiKeyMask); } return `${fxBaseUrl}/api/v2/rates?${params.toString()}`; } return `${fxBaseUrl}/latest?base=${encodeURIComponent(String(base || 'USD').toUpperCase())}`; } function buildExternalCurrenciesUrl() { if (fxProvider === 'currencyapi') { const params = new URLSearchParams({ output: 'json' }); if (fxApiKeyMask) { params.set('key', fxApiKeyMask); } return `${fxCurrenciesUrl}/api/v2/currencies?${params.toString()}`; } return fxCurrenciesUrl; } 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', }); } } function cx() { return Array.from(arguments).filter(Boolean).join(' '); } 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, }); } 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)); } function fmtDate(value) { if (!value) { return 'n/a'; } return value.replace('T', ' ').slice(0, 16); } function fmtDateTime(value) { if (!value) { return 'n/a'; } const normalized = String(value).replace('T', ' '); return normalized.slice(0, 16); } 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; } 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', fx_max_age_hours: 3, preferred_currencies: ['DOGE', 'USD', 'EUR'], cost_plans: [], currencies: [], payouts: [], miner_offers: [], purchased_miners: [], measurement_rates: [], }, 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 }, 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 { 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', }, 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) { 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('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, })))); 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 App() { const [projectKey, setProjectKey] = useState(initialProjectKey); const [activeTab] = 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 [debugEnabled, setDebugEnabled] = useState(initialDebugMode); const [debugConsoleOpen, setDebugConsoleOpen] = useState(initialDebugConsoleOpen); const [debugView, setDebugView] = useState(initialDebugView); const [debugEntries, setDebugEntries] = useState([]); const [schemaStatus, setSchemaStatus] = useState(normalizeSchemaStatus(null)); 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: new Date().toISOString().slice(0, 10), 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', fx_max_age_hours: 3, }); const [moduleAuthForm, setModuleAuthForm] = useState({ required: true, users: '', groups: '', }); const [fxHistory, setFxHistory] = useState([]); const [fxSelection, setFxSelection] = useState(['DOGE', 'USD', 'EUR']); const [fxDisplayBase, setFxDisplayBase] = useState('USD'); const [fxSearch, setFxSearch] = useState(''); 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_speed_value: '', bonus_speed_unit: 'MH/s', 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: '', mining_speed_value: '', mining_speed_unit: 'MH/s', bonus_speed_value: '', bonus_speed_unit: 'MH/s', 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: '', purchased_at: '', total_cost_amount: '', currency: 'USD', reference_price_amount: '', reference_price_currency: '', auto_renew: false, note: '', }); useEffect(() => { debugBus.enabled = debugEnabled; debugBus.listener = (entry) => { setDebugEntries((current) => [entry].concat(current).slice(0, 250)); }; try { window.localStorage.setItem('mining-checker-debug-enabled', debugEnabled ? '1' : '0'); } catch (error) { // Ignore localStorage write failures. } persistDebugCookie(debugEnabled); emitDebug({ type: 'debug:mode', enabled: debugEnabled, }); return () => { debugBus.listener = null; }; }, [debugEnabled]); useEffect(() => { try { window.localStorage.setItem('mining-checker-debug-console-open', debugConsoleOpen ? '1' : '0'); } catch (error) { // Ignore localStorage write failures. } }, [debugConsoleOpen]); useEffect(() => { try { window.localStorage.setItem('mining-checker-debug-view', debugView); } catch (error) { // Ignore localStorage write failures. } }, [debugView]); 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 currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; 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) => !!offer.is_active); 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; if (Number.isFinite(speedMin) && (!Number.isFinite(comparableHashrate) || comparableHashrate < speedMin)) { return false; } if (Number.isFinite(priceMax) && (!Number.isFinite(comparablePrice) || comparablePrice > priceMax)) { return false; } if (Number.isFinite(runtimeMonths) && runtime !== runtimeMonths) { return false; } 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) => Number(offer.id) === Number(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: `${fmtNumber(((Number(miner.mining_speed_value) || 0) + (Number(miner.bonus_speed_value) || 0)), 4)} ${miner.mining_speed_unit || miner.bonus_speed_unit || ''}`.trim(), 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: [ formatSpeed(plan.mining_speed_value, plan.mining_speed_unit, 'Basis'), formatSpeed(plan.bonus_speed_value, plan.bonus_speed_unit, 'Bonus'), ].filter(Boolean).join(' · ') || 'n/a', 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) => Number(offer.id) === Number(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), currency: current.currency || 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]); 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); } const inverseRate = to === baseCurrency ? rates[from] : null; if (Number.isFinite(Number(inverseRate)) && Number(inverseRate) > 0) { return 1 / Number(inverseRate); } 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 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) { setLoading(true); setError(''); setPayload((previous) => previous || normalizeBootstrap(null, key)); let loadGuardTriggered = false; const loadGuard = window.setTimeout(() => { loadGuardTriggered = true; setLoading(false); setPayload((previous) => previous || normalizeBootstrap(null, key)); setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); }, 12000); try { const schema = await request(`${apiBase}/projects/${encodeURIComponent(key)}/schema-status`, { timeoutMs: 4000 }); const nextSchemaStatus = normalizeSchemaStatus(schema); setSchemaStatus(nextSchemaStatus); if (!nextSchemaStatus.all_present) { 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); 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', fx_max_age_hours: normalized.settings.fx_max_age_hours || 3, }); setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length ? normalized.settings.preferred_currencies : ['DOGE', 'USD', 'EUR']); loadFxHistory(key); 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]); useEffect(() => { if (activeTab === 'settings') { loadSchemaStatus(projectKey); loadModuleAuth(); } }, [activeTab, projectKey]); useEffect(() => { if (activeTab === 'currencies') { loadFxHistory(projectKey); } }, [activeTab, 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 comparisonRows = measurements.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: measurements.map((row) => ({ x: row.measured_at.slice(5, 16), y: row.coins_total })), performance: measurements.filter((row) => row.doge_per_day_interval !== null) .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.doge_per_day_interval })), pricing: measurements.filter((row) => row.price_per_coin !== null) .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.price_per_coin })), miningVsPrice: baseMining && basePrice ? [ { key: 'mining-rate', label: 'Mining/h je MH/s Index', color: '#2dd4bf', data: comparisonRows.map((row) => ({ x: row.measured_at.slice(5, 16), y: (Number(row.doge_per_hour_per_mh_interval) / baseMining) * 100, })), }, { key: 'doge-price', label: 'DOGE-Kurs Index', color: '#f59e0b', data: comparisonRows.map((row) => ({ x: row.measured_at.slice(5, 16), y: (Number(row.effective_price_per_coin ?? row.price_per_coin) / basePrice) * 100, })), }, ] : [], }; }, [measurements]); 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); } } 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); 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', fx_max_age_hours: settingsForm.fx_max_age_hours || 3, 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_speed_value: '', bonus_speed_unit: 'MH/s', 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: '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 { await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(minerOfferForm), }); setMessage('Miner-Angebot gespeichert.'); setMinerOfferForm({ label: '', runtime_months: '', mining_speed_value: '', mining_speed_unit: 'MH/s', bonus_speed_value: '', bonus_speed_unit: 'MH/s', 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: new Date().toISOString().slice(0, 19).replace('T', ' ') }), }); setMessage('Miner als gemietet erfasst.'); 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; } await purchaseMinerOffer(purchaseMinerForm.offer_id, { purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 19).replace('T', ' '), 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([]); } } async function refreshSelectedFxRates() { setSaving(true); setError(''); setMessage(''); try { emitDebug({ type: 'external:request-plan', label: 'Ich rufe jetzt extern FX-Rates auf', provider: fxProvider, url: buildExternalFxUrl('USD'), method: 'GET', headers: { Accept: 'application/json' }, }); const probe = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/fx-probe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ base: 'USD' }), timeoutMs: 20000, }); emitDebug({ type: 'external:response', label: 'Hey, hier ist der Response vor dem DB-Schritt', url: probe.url, http_status: probe.http_status, curl_error: probe.curl_error, response_headers: probe.response_headers, response_body: probe.response_body, }); const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/fx-refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ base: 'USD', }), timeoutMs: 15000, }); await loadFxHistory(projectKey); await loadBootstrap(projectKey); setMessage(`Wechselkurse aktualisiert. ${result.updated_count || 0} Datensaetze gespeichert.`); } catch (err) { setError(err.message); } finally { setSaving(false); } } async function refreshCurrencyCatalog() { setSaving(true); setError(''); setMessage(''); try { emitDebug({ type: 'external:request-plan', label: 'Ich rufe jetzt extern den Waehrungskatalog auf', provider: fxProvider, url: buildExternalCurrenciesUrl(), method: 'GET', headers: { Accept: 'application/json' }, }); const probe = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/currencies-probe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeoutMs: 20000, }); emitDebug({ type: 'external:response', label: 'Hey, hier ist der Response vor dem DB-Schritt', url: probe.url, http_status: probe.http_status, curl_error: probe.curl_error, response_headers: probe.response_headers, response_body: probe.response_body, }); const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/currencies-refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeoutMs: 20000, }); setPayload((current) => { const next = normalizeBootstrap(current, projectKey); return { ...next, settings: { ...next.settings, currencies: Array.isArray(result.currencies) ? result.currencies : next.settings.currencies, }, }; }); setMessage(`Waehrungskatalog synchronisiert. ${result.synced_count || 0} Waehrungen verarbeitet.`); } catch (err) { setError(err.message); } finally { setSaving(false); } } async function saveFxSelection() { setSaving(true); setError(''); setMessage(''); try { await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseline_measured_at: currentSettings.baseline_measured_at, baseline_coins_total: currentSettings.baseline_coins_total, daily_cost_amount: currentSettings.daily_cost_amount, daily_cost_currency: currentSettings.daily_cost_currency, report_currency: currentSettings.report_currency || 'EUR', fx_max_age_hours: currentSettings.fx_max_age_hours || 3, preferred_currencies: fxSelection, }), }); await loadBootstrap(projectKey); setMessage('Waehrungs-Auswahl gespeichert.'); } catch (err) { setError(err.message); } finally { setSaving(false); } } async function copyDebugConsole() { const content = debugEntries .slice() .reverse() .map((entry) => `${entry.time} · ${entry.type}\n${JSON.stringify(entry, null, 2)}`) .join('\n\n'); if (!content) { setMessage('Keine Debug-Ausgaben zum Kopieren vorhanden.'); return; } try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(content); } else { const textarea = document.createElement('textarea'); textarea.value = content; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } setMessage('Debug-Inhalt wurde in die Zwischenablage kopiert.'); } catch (err) { setError('Debug-Inhalt konnte nicht kopiert werden.'); } } function addFxSelection(code) { const normalized = String(code || '').toUpperCase().trim(); if (!normalized) { return; } setFxSelection((current) => current.includes(normalized) ? current : current.concat([normalized])); setFxSearch(''); } function removeFxSelection(code) { const normalized = String(code || '').toUpperCase().trim(); setFxSelection((current) => current.filter((item) => item !== normalized)); } function resetReportCurrencyOverride() { setReportCurrencyOverride(''); setCookie('mining_checker_report_currency', '', 0); } const selectedFxCodes = fxSelection.length ? fxSelection.map((code) => String(code || '').toUpperCase()) : ['DOGE', 'USD', 'EUR']; const fxDisplayBaseNormalized = String(fxDisplayBase || 'USD').toUpperCase(); const groupedFxHistoryMap = new Map(); fxHistory.forEach((row, index) => { const fetchId = row.fetch_id || `legacy-${row.fetched_at || 'none'}-${index}`; if (!groupedFxHistoryMap.has(fetchId)) { groupedFxHistoryMap.set(fetchId, { fetch_id: fetchId, fetched_at: row.fetched_at || null, rate_date: row.rate_date || null, base_currency: row.base_currency || null, provider: row.provider || null, rates: {}, }); } const group = groupedFxHistoryMap.get(fetchId); group.rates[String(row.target_currency || '').toUpperCase()] = row.rate; }); function computeDisplayedFxRate(group, targetCode, displayBaseCode) { const normalizedTarget = String(targetCode || '').toUpperCase(); const normalizedDisplayBase = String(displayBaseCode || '').toUpperCase(); const fetchBase = String(group.base_currency || '').toUpperCase(); if (!normalizedTarget || !normalizedDisplayBase) { return null; } if (normalizedTarget === normalizedDisplayBase) { return 1; } const targetRateRaw = normalizedTarget === fetchBase ? 1 : group.rates[normalizedTarget]; const displayBaseRateRaw = normalizedDisplayBase === fetchBase ? 1 : group.rates[normalizedDisplayBase]; const targetRate = targetRateRaw === null || targetRateRaw === undefined ? null : Number(targetRateRaw); const displayBaseRate = displayBaseRateRaw === null || displayBaseRateRaw === undefined ? null : Number(displayBaseRateRaw); if (!Number.isFinite(targetRate) || !Number.isFinite(displayBaseRate) || displayBaseRate === 0) { return null; } return targetRate / displayBaseRate; } const groupedFxHistory = Array.from(groupedFxHistoryMap.values()) .map((group) => ({ ...group, selected_rates: selectedFxCodes.map((code) => ({ code, rate: computeDisplayedFxRate(group, code, fxDisplayBaseNormalized), })), })) .filter((group) => group.selected_rates.some((item) => item.rate !== null)) .slice(0, 30); const debugConsoleText = debugEntries .map((entry) => `${entry.time} · ${entry.type}\n${JSON.stringify(entry, null, 2)}`) .join('\n\n'); 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-empty' }, 'Lade Mining-Checker Daten …') : null, payload ? renderTab() : null, h('div', { key: 'debug-tools', className: 'mc-debug-tools' }, [ h('button', { key: 'debug-toggle', type: 'button', className: cx('mc-button', debugEnabled ? 'mc-button--secondary' : 'mc-button--ghost'), onClick: () => { const next = !debugEnabled; setDebugEnabled(next); if (next) { setDebugConsoleOpen(true); } }, }, debugEnabled ? 'Debug aktiv' : 'Debug einschalten'), h('button', { key: 'console-toggle', type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setDebugConsoleOpen((current) => { const next = !current; if (next) { setDebugEnabled(true); } return next; }), }, debugConsoleOpen ? 'Online-Konsole ausblenden' : 'Online-Konsole einblenden'), debugEntries.length ? h('button', { key: 'debug-clear', type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setDebugEntries([]), }, 'Konsole leeren') : null, debugEntries.length ? h('button', { key: 'debug-copy', type: 'button', className: 'mc-button mc-button--ghost', onClick: copyDebugConsole, }, 'Copy content') : null, ]), debugConsoleOpen ? h('section', { key: 'debug-console', className: 'mc-panel mc-debug-console' }, [ h(SectionTitle, { key: 'debug-title', title: 'Online-Konsole', subtitle: 'Zeigt API-, Provider- und DB-Debugspuren direkt aus dem Modul.', action: h(Badge, { tone: debugEnabled ? 'success' : 'warn' }, debugEnabled ? 'Debug aktiv' : 'Debug aus'), }), h('div', { key: 'debug-body', className: 'mc-panel-body' }, [ h('div', { key: 'debug-view-switch', className: 'mc-debug-view-switch' }, [ h('button', { key: 'view-structured', type: 'button', className: cx('mc-button', debugView === 'structured' ? 'mc-button--tab-active' : 'mc-button--tab'), onClick: () => setDebugView('structured'), }, 'Strukturiert'), h('button', { key: 'view-text', type: 'button', className: cx('mc-button', debugView === 'text' ? 'mc-button--tab-active' : 'mc-button--tab'), onClick: () => setDebugView('text'), }, 'Text-Konsole'), ]), debugView === 'text' ? h('pre', { className: 'mc-code-block mc-debug-text-console' }, debugConsoleText || 'Noch keine Debug-Ausgaben vorhanden.') : h('div', { className: 'mc-debug-log' }, debugEntries.length ? debugEntries.map((entry) => h('div', { key: entry.id, className: 'mc-debug-entry' }, [ h('div', { key: 'meta', className: 'mc-kicker' }, `${entry.time} · ${entry.type}`), h('pre', { key: 'payload', className: 'mc-code-block' }, JSON.stringify(entry, null, 2)), ])) : h('div', { className: 'mc-empty' }, 'Noch keine Debug-Ausgaben vorhanden.') ), ]), ]) : null, ]), ]); function renderTab() { 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.invested_capital, reportCurrency) : null; const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; const breakEvenDate = (() => { if (!latest || breakEvenDaysOverall === null || !Number.isFinite(breakEvenDaysOverall)) { return null; } const baseTimestamp = Date.parse(String(latest.measured_at || '')); if (!Number.isFinite(baseTimestamp)) { return null; } return new Date(baseTimestamp + (breakEvenDaysOverall * 24 * 60 * 60 * 1000)); })(); 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: 'Coins sichtbar', value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a', sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', }), h(StatCard, { key: 'coins-effective', label: 'Coins effektiv', value: payload?.summary?.payouts ? fmtNumber(payload.summary.payouts.current_effective_coins, 6) : 'n/a', sub: payload?.summary?.payouts ? `Ausgezahlt ${fmtNumber(payload.summary.payouts.total_coins, 6)} DOGE` : '', }), h(StatCard, { key: 'perday', label: 'DOGE pro Tag', 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)} · Break-even ${breakEvenPrice !== null ? `${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : 'n/a'}` : 'Kein aktiver Miner fuer diese Waehrung', }), h(StatCard, { key: 'break-even-point', label: 'Break-even', value: breakEvenDaysOverall !== null ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` : (breakEvenReached ? 'Erreicht' : (investedCapital === null ? 'Keine Mietbasis' : 'n/a')), sub: investedCapital !== null ? `${breakEvenDate ? `Theoretisch ${fmtDate(breakEvenDate.toISOString())} · ` : ''}Basis ${fmtMoney(investedCapital, reportCurrency)}${dailyRevenue !== null ? ` · Tagesumsatz ${fmtMoney(dailyRevenue, 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', 'DOGE-pro-Tag-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 DOGE pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.', h(SimpleChart, { type: 'line', series: overviewCharts.miningVsPrice, })), ]), panel('Zielmonitor', 'Rest-DOGE 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 DOGE: ${fmtNumber(target.required_doge, 6)}`), h('div', { key: 'remaining' }, `Rest-DOGE: ${fmtNumber(target.remaining_doge, 6)}`), h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`), ]), ])) ) ), ]); } if (activeTab === 'measurements') { return h('div', { className: 'mc-main-grid' }, [ h('div', { className: 'mc-stack' }, [ (function () { const preview = normalizeOcrPreview(ocrPreview); const hasUsableOcrSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null; 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: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), ]), h('div', { key: 'form', className: 'mc-two-col' }, [ 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 den Messpunkt manuell erfassen.') : null, h('button', { key: 'confirm', type: 'button', className: 'mc-button mc-button--primary', onClick: () => submitMeasurement(true), disabled: saving || !hasUsableOcrSuggestion, }, saving ? 'Speichert …' : 'Ergebnis speichern'), ]) : h('div', { key: 'ocr-empty', className: 'mc-empty' }, 'Noch kein Screenshot ausgewaehlt.'), ]); })(), panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen 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'), ])), panel('Import per Copy & Paste', 'Mehrere historische 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'), ])), 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('Messhistorie', 'Die letzten 10 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', 'DOGE/Tag', '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)), 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 === '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 === 'currencies') { const selectedSet = new Set((fxSelection || []).map((code) => String(code).toUpperCase())); const searchNeedle = fxSearch.trim().toLowerCase(); const currencySuggestions = currencies .filter((currency) => !selectedSet.has(String(currency.code || '').toUpperCase())) .filter((currency) => { if (!searchNeedle) { return false; } const code = String(currency.code || '').toLowerCase(); const name = String(currency.name || '').toLowerCase(); return code.includes(searchNeedle) || name.includes(searchNeedle); }) .slice(0, 12); return h('div', { className: 'mc-stack' }, [ h('div', { className: 'mc-stack' }, [ panel('Waehrungs-Update', 'Auswahl wird in den Mining-Checker-Settings gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.', [ h('div', { key: 'actions', className: 'mc-inline-row' }, [ h('button', { key: 'save-selection', type: 'button', className: 'mc-button mc-button--ghost', onClick: saveFxSelection, disabled: saving, }, saving ? 'Speichert …' : 'Auswahl speichern'), h('button', { key: 'refresh-rates', type: 'button', className: 'mc-button mc-button--primary', onClick: refreshSelectedFxRates, disabled: saving, }, saving ? 'Aktualisiert …' : 'Alle Wechselkurse aktualisieren'), h('button', { key: 'refresh-catalog', type: 'button', className: 'mc-button mc-button--ghost', onClick: refreshCurrencyCatalog, disabled: saving, }, saving ? 'Synchronisiert …' : 'Waehrungskatalog sync'), ]), h('div', { key: 'types', className: 'mc-mini-grid' }, [ h('div', { key: 'fiat', className: 'mc-mini-card' }, [ h('div', { key: 'fiat-label', className: 'mc-field-label' }, 'Fiat'), h('div', { key: 'fiat-value' }, `${fiatCurrencies.length} Waehrungen`), ]), h('div', { key: 'crypto', className: 'mc-mini-card' }, [ h('div', { key: 'crypto-label', className: 'mc-field-label' }, 'Krypto'), h('div', { key: 'crypto-value' }, `${cryptoCurrencies.length} Waehrungen`), ]), ]), h('div', { key: 'selection-title', className: 'mc-field-label' }, 'Bevorzugte Waehrungen fuer Anzeige'), h('div', { key: 'selection-row', className: 'mc-currency-selection-row' }, [ h('div', { key: 'selected', className: 'mc-token-list mc-token-list--inline' }, fxSelection.length ? fxSelection.map((code) => { const currency = currencies.find((item) => item.code === code) || { code, name: code }; return h('button', { key: `token-${code}`, type: 'button', className: 'mc-token', onClick: () => removeFxSelection(code), title: `${code} entfernen`, }, [ h('span', { key: 'label' }, `${code} (${currency.name || code})`), h('span', { key: 'close', className: 'mc-token-close' }, 'x'), ]); }) : [h('div', { key: 'empty', className: 'mc-text' }, 'Noch keine bevorzugten Waehrungen ausgewaehlt.')] ), h('div', { key: 'search-wrap', className: 'mc-field mc-currency-search' }, [ h('input', { key: 'search-input', type: 'text', value: fxSearch, className: 'mc-input', placeholder: 'Waehrung hinzufuegen: EUR, USD, DOGE oder Euro', onInput: (event) => setFxSearch(event.target.value), }), ]), ]), h('div', { key: 'display-base-wrap', className: 'mc-field mc-currency-search' }, [ h('label', { key: 'display-base-label', className: 'mc-field-label' }, 'Darstellung auf Basis von'), h('select', { key: 'display-base', className: 'mc-select', value: selectedFxCodes.includes(fxDisplayBaseNormalized) ? fxDisplayBaseNormalized : (selectedFxCodes[0] || 'USD'), onChange: (event) => setFxDisplayBase(event.target.value), }, selectedFxCodes.map((code) => h('option', { key: code, value: code }, code))), ]), searchNeedle ? h('div', { key: 'suggestions', className: 'mc-suggestion-list' }, currencySuggestions.length ? currencySuggestions.map((currency) => h('button', { key: `suggestion-${currency.code}`, type: 'button', className: 'mc-suggestion', onClick: () => addFxSelection(currency.code), }, [ h('strong', { key: 'code' }, currency.code), h('span', { key: 'name' }, currency.name || currency.code), ])) : [h('div', { key: 'no-match', className: 'mc-text' }, 'Keine passende Waehrung gefunden.')] ) : null, ]), ]), h('div', { className: 'mc-stack' }, [ panel('Letzte 30 Kurs-Uploads', 'Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.', [ h('div', { key: 'history-table', className: 'mc-table-shell' }, [ h('table', { key: 'table', className: 'mc-table' }, [ h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Stichtag', 'Fetch-Basis'].concat(selectedFxCodes).concat(['Provider']).map((label) => h('th', { key: label }, label)))), h('tbody', { key: 'body' }, groupedFxHistory.length ? groupedFxHistory.map((row, index) => h('tr', { key: `${row.fetch_id || index}-${row.base_currency}` }, [ h('td', { key: 'fetched' }, fmtDate(row.fetched_at)), h('td', { key: 'date' }, row.rate_date || 'n/a'), h('td', { key: 'base' }, row.base_currency), ].concat( row.selected_rates.map((item) => h('td', { key: `rate-${item.code}` }, item.rate === null ? 'n/a' : fmtNumber(item.rate, 8))) ).concat([ h('td', { key: 'provider' }, row.provider || 'n/a'), ]))) : [h('tr', { key: 'empty' }, h('td', { colSpan: 4 + selectedFxCodes.length }, 'Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.'))] ), ]), ]), ]), ]), ]); } 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: () => 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: () => 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', 'Angebote fuer neue Miner und eine grobe Reinvestitionsbewertung auf Basis der aktuellen Leistung.', [ h('div', { key: 'actions', className: 'mc-inline-row' }, [ h('button', { key: 'add-offer', type: 'button', className: 'mc-button mc-button--secondary', onClick: () => setMinerOfferModalOpen(true), }, 'Miner-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)} DOGE` : '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 && offer.payment_type !== 'crypto' ? 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', Number(selectedMinerScenario?.id) === Number(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: String(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), purchased_at: new Date().toISOString().slice(0, 16), 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: 'DOGE 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 DOGE/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-Geschwindigkeit', 'number', costPlanForm.bonus_speed_value, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_value: value }), '0.0001'), selectField('Bonus-Einheit', costPlanForm.bonus_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_unit: value })), 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, ['DOGE'].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== 'DOGE')), (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('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 })), inputField('Mining-Geschwindigkeit', 'number', minerOfferForm.mining_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_value: value }), '0.0001'), selectField('Mining-Einheit', minerOfferForm.mining_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_unit: value })), inputField('Bonus-Geschwindigkeit', 'number', minerOfferForm.bonus_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_value: value }), '0.0001'), selectField('Bonus-Einheit', minerOfferForm.bonus_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_unit: value })), inputField('Basispreis', 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), selectField('Basiswährung', minerOfferForm.base_price_currency, selectableFiatCurrencies.map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ ...minerOfferForm, payment_type: 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, purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 16), 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('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(availableMinerOffers.map((offer) => ({ value: String(offer.id), label: `${offer.label} · ${fmtNumber(offer.base_price_amount ?? offer.effective_price_amount, 6)} ${offer.base_price_currency || offer.effective_price_currency}`, }))), (value) => { const offer = availableMinerOffers.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', settingsForm.baseline_measured_at ? settingsForm.baseline_measured_at.replace(' ', 'T').slice(0, 16) : '', (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 })), inputField('FX maximal in Stunden wiederverwenden', 'number', String(settingsForm.fx_max_age_hours || 3), (value) => setSettingsForm({ ...settingsForm, fx_max_age_hours: value }), '0.25'), 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 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)); })();