2706 lines
126 KiB
JavaScript
2706 lines
126 KiB
JavaScript
(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 initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview';
|
|
const configuredSections = (() => {
|
|
try {
|
|
const parsed = JSON.parse(root.dataset.sectionsJson || '[]');
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed
|
|
.map((section) => {
|
|
const key = section && typeof section.key === 'string' ? section.key.trim() : '';
|
|
const label = section && typeof section.label === 'string' ? section.label.trim() : '';
|
|
return key && label ? [key, label] : null;
|
|
})
|
|
.filter(Boolean);
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
})();
|
|
const initialDebugMode = document.body && document.body.dataset.nexusDebugEnabled === '1';
|
|
function getCookie(name) {
|
|
const pattern = `; ${document.cookie}`;
|
|
const parts = pattern.split(`; ${name}=`);
|
|
if (parts.length < 2) {
|
|
return '';
|
|
}
|
|
return decodeURIComponent(parts.pop().split(';').shift() || '');
|
|
}
|
|
|
|
function setCookie(name, value, maxAgeSeconds) {
|
|
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; samesite=lax`;
|
|
}
|
|
const debugBus = window.__nexusDebugBus || {
|
|
enabled: initialDebugMode,
|
|
listener: null,
|
|
sequence: 0,
|
|
};
|
|
window.__nexusDebugBus = debugBus;
|
|
|
|
function emitDebug(entry) {
|
|
debugBus.sequence += 1;
|
|
const payload = {
|
|
id: debugBus.sequence,
|
|
time: new Date().toISOString(),
|
|
...entry,
|
|
};
|
|
|
|
if (typeof debugBus.listener === 'function') {
|
|
debugBus.listener(payload);
|
|
}
|
|
}
|
|
|
|
function emitServerTraceEntries(trace, meta) {
|
|
if (!Array.isArray(trace) || !trace.length) {
|
|
emitDebug({
|
|
type: meta && meta.type ? meta.type : 'server:trace-empty',
|
|
source: meta && meta.source ? meta.source : null,
|
|
message: 'Keine Server-Debug-Eintraege vorhanden.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
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 : {}),
|
|
});
|
|
});
|
|
}
|
|
|
|
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',
|
|
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) {
|
|
function pointTooltip(seriesLabel, point) {
|
|
const label = seriesLabel ? `${seriesLabel} · ` : '';
|
|
return `${label}${String(point.x)}: ${fmtNumber(point.y, 6)}`;
|
|
}
|
|
|
|
const series = Array.isArray(props.series)
|
|
? props.series
|
|
.map((item, index) => ({
|
|
key: item && item.key ? String(item.key) : `series-${index}`,
|
|
label: item && item.label ? String(item.label) : `Serie ${index + 1}`,
|
|
color: item && item.color ? String(item.color) : ['#60a5fa', '#2dd4bf', '#f59e0b', '#f472b6'][index % 4],
|
|
data: Array.isArray(item && item.data)
|
|
? item.data.filter((point) => point && point.y !== null && point.y !== undefined)
|
|
: [],
|
|
}))
|
|
.filter((item) => item.data.length > 0)
|
|
: [];
|
|
const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : [];
|
|
const isMultiSeries = series.length > 0;
|
|
const activeSeries = isMultiSeries ? series : [{
|
|
key: 'default',
|
|
label: props.yLabel || 'Wert',
|
|
color: props.type === 'area' ? '#2dd4bf' : '#60a5fa',
|
|
data: points,
|
|
}];
|
|
const allPoints = activeSeries.flatMap((item) => item.data);
|
|
|
|
if (!allPoints.length) {
|
|
return h('div', { className: 'mc-empty' }, 'Keine Daten fuer diese Ansicht.');
|
|
}
|
|
|
|
if (props.type === 'table') {
|
|
return h('div', { className: 'mc-table-shell' }, [
|
|
h('table', { key: 'table', className: 'mc-table' }, [
|
|
h('thead', { key: 'head' }, h('tr', null, [
|
|
h('th', { key: 'x' }, props.xLabel || 'X'),
|
|
h('th', { key: 'y' }, props.yLabel || 'Y'),
|
|
])),
|
|
h('tbody', { key: 'body' },
|
|
points.map((point, index) => h('tr', { key: index }, [
|
|
h('td', { key: 'x' }, String(point.x)),
|
|
h('td', { key: 'y' }, fmtNumber(point.y, 6)),
|
|
]))
|
|
),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
const width = 640;
|
|
const height = 220;
|
|
const padding = 24;
|
|
const values = allPoints.map((point) => Number(point.y));
|
|
const minY = Math.min.apply(null, values);
|
|
const maxY = Math.max.apply(null, values);
|
|
const range = maxY - minY || 1;
|
|
const seriesCoords = activeSeries.map((item) => {
|
|
const stepX = item.data.length > 1 ? (width - padding * 2) / (item.data.length - 1) : 0;
|
|
const coords = item.data.map((point, index) => {
|
|
const x = padding + stepX * index;
|
|
const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2);
|
|
return [x, y];
|
|
});
|
|
|
|
return {
|
|
...item,
|
|
coords,
|
|
line: coords.map((coord) => coord.join(',')).join(' '),
|
|
area: coords.length
|
|
? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]])
|
|
.map((coord) => coord.join(',')).join(' ')
|
|
: '',
|
|
};
|
|
});
|
|
|
|
return h('div', { className: 'mc-chart space-y-3' }, [
|
|
h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [
|
|
h('span', { key: 'min' }, 'Min ' + fmtNumber(minY, 4)),
|
|
h('span', { key: 'max' }, 'Max ' + fmtNumber(maxY, 4)),
|
|
]),
|
|
h('svg', { key: 'svg', viewBox: `0 0 ${width} ${height}`, className: 'overflow-visible' }, [
|
|
h('g', { key: 'grid', stroke: 'rgba(255,255,255,0.08)' }, [
|
|
h('line', { key: 'top', x1: padding, x2: width - padding, y1: padding, y2: padding }),
|
|
h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }),
|
|
h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }),
|
|
]),
|
|
props.type === 'bar' && !isMultiSeries
|
|
? h('g', { key: 'bars' }, seriesCoords[0].coords.map((coord, index) => {
|
|
const barWidth = Math.max(10, (((width - padding * 2) / Math.max(seriesCoords[0].coords.length - 1, 1)) * 0.6) || 24);
|
|
return h('rect', {
|
|
key: index,
|
|
x: coord[0] - barWidth / 2,
|
|
y: coord[1],
|
|
width: barWidth,
|
|
height: height - padding - coord[1],
|
|
rx: 8,
|
|
fill: 'rgba(59, 130, 246, 0.75)',
|
|
}, [
|
|
h('title', { key: 'title' }, pointTooltip(seriesCoords[0].label, seriesCoords[0].data[index])),
|
|
]);
|
|
}))
|
|
: h('g', { key: 'series' }, seriesCoords.flatMap((item, index) => {
|
|
const nodes = [];
|
|
if (props.type === 'area' && !isMultiSeries && item.area) {
|
|
nodes.push(h('polygon', {
|
|
key: `${item.key}-area`,
|
|
points: item.area,
|
|
fill: 'rgba(45, 212, 191, 0.18)',
|
|
}));
|
|
}
|
|
nodes.push(h('polyline', {
|
|
key: `${item.key}-line`,
|
|
points: item.line,
|
|
fill: 'none',
|
|
stroke: item.color,
|
|
strokeWidth: 3,
|
|
strokeLinecap: 'round',
|
|
strokeLinejoin: 'round',
|
|
}));
|
|
nodes.push(h('g', { key: `${item.key}-dots` }, item.coords.map((coord, pointIndex) => h('circle', {
|
|
key: `${item.key}-${pointIndex}`,
|
|
cx: coord[0],
|
|
cy: coord[1],
|
|
r: 4,
|
|
fill: '#f8fafc',
|
|
stroke: item.color,
|
|
strokeWidth: 2,
|
|
}, [
|
|
h('title', { key: 'title' }, pointTooltip(item.label, item.data[pointIndex])),
|
|
]))));
|
|
return nodes;
|
|
})),
|
|
]),
|
|
isMultiSeries
|
|
? h('div', { key: 'legend', className: 'mc-mini-grid' },
|
|
seriesCoords.map((item) => {
|
|
const latestPoint = item.data[item.data.length - 1] || null;
|
|
return h('div', { key: item.key, className: 'mc-mini-card' }, [
|
|
h('div', { key: 'label', className: 'mc-kicker', style: { color: item.color } }, item.label),
|
|
h('div', { key: 'value' }, latestPoint ? `${fmtNumber(latestPoint.y, 4)} · ${latestPoint.x}` : 'n/a'),
|
|
]);
|
|
})
|
|
)
|
|
: h('div', { key: 'labels', className: 'mc-mini-grid' },
|
|
points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`))
|
|
),
|
|
]);
|
|
}
|
|
|
|
function DashboardCard(props) {
|
|
return h('div', { className: 'mc-dashboard-card' }, [
|
|
h('div', { key: 'head', className: 'mc-flex-split' }, [
|
|
h('div', { key: 'titles' }, [
|
|
h('h3', { key: 'name' }, props.definition.name),
|
|
h('p', { key: 'meta', className: 'mc-kicker' },
|
|
`${props.definition.chart_type} · ${props.definition.x_field} → ${props.definition.y_field} · ${props.definition.aggregation}`),
|
|
]),
|
|
h(Badge, { key: 'badge' }, props.definition.is_active ? 'aktiv' : 'inaktiv'),
|
|
]),
|
|
props.loading
|
|
? h('div', { key: 'loading', className: 'mc-empty' }, 'Lade Dashboarddaten …')
|
|
: h(SimpleChart, {
|
|
key: 'chart',
|
|
type: props.definition.chart_type,
|
|
data: props.data || [],
|
|
xLabel: props.definition.x_field,
|
|
yLabel: props.definition.y_field,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function 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 [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',
|
|
});
|
|
const [moduleAuthForm, setModuleAuthForm] = useState({
|
|
required: true,
|
|
users: '',
|
|
groups: '',
|
|
});
|
|
const [fxHistory, setFxHistory] = useState([]);
|
|
const [fxSelection, setFxSelection] = useState(['DOGE', 'USD', 'EUR']);
|
|
const [reportCurrencyOverride, setReportCurrencyOverride] = useState(() => {
|
|
const value = String(getCookie('mining_checker_report_currency') || '').toUpperCase();
|
|
return /^[A-Z0-9]{3,10}$/.test(value) ? value : '';
|
|
});
|
|
const [targetForm, setTargetForm] = useState({
|
|
label: '',
|
|
target_amount_fiat: '',
|
|
currency: 'EUR',
|
|
miner_offer_id: '',
|
|
is_active: true,
|
|
sort_order: 0,
|
|
});
|
|
const [targetModalOpen, setTargetModalOpen] = useState(false);
|
|
const [selectedMinerScenarioId, setSelectedMinerScenarioId] = useState(null);
|
|
const [minerOfferFilters, setMinerOfferFilters] = useState({
|
|
speed_min: '',
|
|
speed_unit: 'auto',
|
|
price_max: '',
|
|
runtime_months: '',
|
|
});
|
|
const [costPlanForm, setCostPlanForm] = useState({
|
|
label: '',
|
|
starts_at: '',
|
|
runtime_months: 1,
|
|
mining_speed_value: '',
|
|
mining_speed_unit: 'MH/s',
|
|
bonus_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 = initialDebugMode;
|
|
emitDebug({
|
|
type: 'debug:mode',
|
|
enabled: initialDebugMode,
|
|
});
|
|
}, []);
|
|
|
|
const measurements = Array.isArray(payload?.measurements) ? payload.measurements : [];
|
|
const latest = payload?.summary?.latest_measurement || null;
|
|
const currentSettings = payload?.settings || {
|
|
cost_plans: [],
|
|
currencies: [],
|
|
};
|
|
const reportCurrency = reportCurrencyOverride || currentSettings.report_currency || 'EUR';
|
|
const currentTargets = Array.isArray(payload?.summary?.targets) ? payload.summary.targets : [];
|
|
const currentDashboards = Array.isArray(payload?.dashboards) ? payload.dashboards : [];
|
|
const currencies = Array.isArray(currentSettings.currencies) && currentSettings.currencies.length
|
|
? currentSettings.currencies
|
|
: [
|
|
{ code: 'EUR', name: 'Euro' },
|
|
{ code: 'USD', name: 'US-Dollar' },
|
|
{ code: 'DOGE', name: 'Dogecoin' },
|
|
{ code: 'BTC', name: 'Bitcoin' },
|
|
{ code: 'ETH', name: 'Ethereum' },
|
|
{ code: 'LTC', name: 'Litecoin' },
|
|
{ code: 'USDT', name: 'Tether' },
|
|
{ code: 'USDC', name: 'USD Coin' },
|
|
];
|
|
const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : [];
|
|
const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : [];
|
|
const 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',
|
|
});
|
|
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(() => {
|
|
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',
|
|
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([]);
|
|
}
|
|
}
|
|
|
|
function resetReportCurrencyOverride() {
|
|
setReportCurrencyOverride('');
|
|
setCookie('mining_checker_report_currency', '', 0);
|
|
}
|
|
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,
|
|
]),
|
|
]);
|
|
|
|
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 === '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 })),
|
|
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));
|
|
})();
|