Letzter Abruf
${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}
(function () { function parseSections(root, options) { const configured = Array.isArray(options.sections) ? options.sections : null; if (configured) { return configured; } try { const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); return Array.isArray(parsed) ? parsed : []; } catch (_error) { return []; } } function cx() { return Array.from(arguments).filter(Boolean).join(' '); } function escapeHtml(value) { return String(value || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function fmtNumber(value, digits) { if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) { return 'n/a'; } return Number(value).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: digits === undefined ? 6 : digits, }); } function fmtDate(value) { const raw = String(value || '').trim(); if (!raw) { return 'n/a'; } const parsed = new Date(raw.replace(' ', 'T')); if (Number.isNaN(parsed.getTime())) { return raw; } return parsed.toLocaleString('de-DE'); } function readMessage(error) { return error && error.message ? error.message : 'Unbekannter Fehler'; } function createFxRatesApp(root, options) { const apiBase = options.apiBase || root.dataset.apiBase || '/api/fx-rates/index.php?path=v1'; const sectionDefs = parseSections(root, options); const fallbackSections = [ { key: 'overview', label: 'Uebersicht', summary: 'Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung.' }, { key: 'currencies', label: 'Waehrungen', summary: 'Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten.' }, { key: 'settings', label: 'Setup', summary: 'Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen.' }, ]; const sectionMap = new Map(); sectionDefs.concat(fallbackSections).forEach((section) => { const key = String(section.key || '').trim(); if (!key || sectionMap.has(key)) { return; } sectionMap.set(key, { key, label: String(section.label || key), summary: String(section.summary || ''), }); }); const sections = Array.from(sectionMap.values()); const initialTab = String(options.activeView || root.dataset.activeView || 'overview').trim() || 'overview'; const state = { activeTab: sectionMap.has(initialTab) ? initialTab : sections[0].key, loading: true, saving: false, syncingCatalog: false, message: '', error: '', settings: null, statuses: [], recentFetches: [], conversion: null, history: [], currencyProbe: null, }; function setState(patch) { Object.assign(state, patch); render(); } async function request(path, options) { const buildApiUrl = (relativePath) => { const url = new URL(apiBase, window.location.origin); const [resourcePath, queryString] = String(relativePath || '').split('?'); const currentPath = url.searchParams.get('path') || ''; url.searchParams.set('path', `${currentPath.replace(/\/+$/, '')}/${String(resourcePath || '').replace(/^\/+/, '')}`.replace(/^\/+/, '')); if (queryString) { const extraParams = new URLSearchParams(queryString); extraParams.forEach((value, key) => { url.searchParams.set(key, value); }); } return url.toString(); }; const response = await fetch(buildApiUrl(path), { method: options && options.method ? options.method : 'GET', credentials: 'same-origin', headers: { Accept: 'application/json', ...(options && options.body ? { 'Content-Type': 'application/json' } : {}), }, body: options && options.body ? JSON.stringify(options.body) : undefined, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`); } return payload.data; } function currentSettings() { return state.settings || { preferred_currencies: ['EUR', 'USD'], default_base_currency: 'EUR', display_base_currency: 'EUR', refresh_max_age_hours: 6, refresh_max_age_minutes: 360, currency_catalog: [], }; } function historyPairs() { const settings = currentSettings(); const base = String(settings.display_base_currency || settings.default_base_currency || 'EUR').toUpperCase(); const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : []; return preferred .map((code) => String(code || '').toUpperCase()) .filter((code) => code && code !== base) .slice(0, 4) .map((code) => ({ from: base, to: code })); } async function loadOverviewData() { const [statuses, recentFetches] = await Promise.all([ request('status'), request('recent-fetches?limit=15'), ]); const pairs = historyPairs(); const historyRows = await Promise.all(pairs.map(async (pair) => ({ pair, rows: await request(`history?from=${encodeURIComponent(pair.from)}&to=${encodeURIComponent(pair.to)}&limit=12`), }))); setState({ statuses: Array.isArray(statuses) ? statuses : [], recentFetches: Array.isArray(recentFetches) ? recentFetches : [], history: historyRows, }); } async function loadBootstrap() { try { const [settings, currencyProbe] = await Promise.all([ request('settings'), request('currency-catalog/probe').catch(() => null), ]); setState({ settings, currencyProbe, loading: false, error: '', }); await loadOverviewData(); } catch (error) { setState({ loading: false, error: readMessage(error), }); } } async function runRefresh(force) { setState({ saving: true, error: '', message: '', }); try { const result = await request('refresh', { method: 'POST', body: { force: force === true, trigger_source: 'manual', }, }); await loadOverviewData(); setState({ saving: false, message: result && result.reused ? `Kein neuer Abruf. Letzter Snapshot ist noch innerhalb von ${fmtNumber(currentSettings().refresh_max_age_hours, 2)} Stunden.` : `Aktuelle Kurse gespeichert. ${fmtNumber(result?.updated_count, 0)} Werte aktualisiert.`, }); } catch (error) { setState({ saving: false, error: readMessage(error), }); } } async function runConversion(event) { event.preventDefault(); const form = event.currentTarget; const formData = new FormData(form); const amount = Number(formData.get('amount') || 0); const from = String(formData.get('from') || '').toUpperCase(); const to = String(formData.get('to') || '').toUpperCase(); try { const rate = await request(`rate?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); const resolved = Number(rate?.rate || 0); if (!Number.isFinite(resolved) || resolved <= 0) { throw new Error('Kein passender Kurs verfuegbar.'); } setState({ conversion: { amount, from, to, rate: resolved, converted: amount * resolved, }, error: '', message: '', }); } catch (error) { setState({ conversion: null, error: readMessage(error), }); } } async function saveSettings(event) { event.preventDefault(); const form = event.currentTarget; const formData = new FormData(form); const preferred = String(formData.get('preferred_currencies') || '') .split(',') .map((item) => String(item || '').trim().toUpperCase()) .filter(Boolean); setState({ saving: true, error: '', message: '' }); try { const body = {}; ['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'].forEach((key) => { if (formData.has(key)) { body[key] = formData.get(key); } }); if (formData.has('timeout_sec')) { body.timeout_sec = Number(formData.get('timeout_sec') || 10); } if (formData.has('refresh_max_age_hours')) { body.refresh_max_age_hours = Number(formData.get('refresh_max_age_hours') || 6); } if (formData.has('preferred_currencies')) { body.preferred_currencies = preferred; } const settings = await request('settings', { method: 'PUT', body, }); setState({ saving: false, settings, message: 'Setup gespeichert.', }); await loadOverviewData(); } catch (error) { setState({ saving: false, error: readMessage(error), }); } } async function syncCatalog() { setState({ syncingCatalog: true, error: '', message: '' }); try { const result = await request('currency-catalog/sync', { method: 'POST', body: {}, }); setState({ syncingCatalog: false, settings: result?.settings || state.settings, message: `Waehrungskatalog synchronisiert. ${fmtNumber(result?.sync?.synced_count, 0)} Eintraege geladen.`, }); } catch (error) { setState({ syncingCatalog: false, error: readMessage(error), }); } } function openWidgetSetup() { window.dispatchEvent(new CustomEvent('desktop:open-app', { detail: { appId: 'user-self-management', }, })); } function renderNav() { return ` `; } function renderOverview() { const settings = currentSettings(); const latest = state.statuses[0] || null; const base = String(settings.default_base_currency || 'EUR').toUpperCase(); const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : []; return `
Aktualisierung
Das Oeffnen der App loest keinen Abruf aus. Manuell und per API wird nur aktualisiert, wenn die bestehenden Kurse aelter als ${fmtNumber(settings.refresh_max_age_hours, 2)} Stunden sind, ausser mit force.
Letzter Abruf
${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}
Standard-Basis
Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}
Bevorzugte Waehrungen
${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}
Umrechnung
${state.conversion ? `${fmtNumber(state.conversion.amount, 8)} ${state.conversion.from} = ${fmtNumber(state.conversion.converted, 8)} ${state.conversion.to} bei Kurs ${fmtNumber(state.conversion.rate, 8)}` : 'Noch keine Umrechnung berechnet.'}
Widget
Verlauf
| Paar | Zeit | Kurs | Quelle |
|---|---|---|---|
| Noch keine Verlaufsdaten vorhanden. | |||
| ${index === 0 ? escapeHtml(pair) : ''} | ${escapeHtml(fmtDate(row.fetched_at_display || row.fetched_at))} | ${escapeHtml(fmtNumber(row.rate, 8))} | ${escapeHtml(row.provider || 'n/a')} |
Abrufe
| Zeit | Basis | Provider | Ausloeser |
|---|---|---|---|
| Noch keine Abrufe gespeichert. | |||
| ${escapeHtml(fmtDate(fetch.fetched_at_display || fetch.fetched_at))} | ${escapeHtml(fetch.base_currency || 'n/a')} | ${escapeHtml(fetch.provider || 'n/a')} | ${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')} |
Katalog
Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.
Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}
Verfuegbarer Katalog
Waehrungs-Checker
${escapeHtml(current.summary)}
Waehrungsdaten werden geladen.