(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

Externer Kursabruf

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.

${escapeHtml(state.error || state.message || '')}

Letzter Abruf

${escapeHtml(latest ? fmtDate(latest.fetched_at_display || latest.fetched_at) : 'Noch keiner')}

${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}

Standard-Basis

${escapeHtml(base)}

Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}

Bevorzugte Waehrungen

${escapeHtml(String(preferred.length || 0))}

${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}

Umrechnung

Direkter Kursvergleich

${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

Desktop-Infobereich

Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.

Verlauf

Neueste Kursverlaeufe

${state.history.length === 0 || state.history.every((entry) => !Array.isArray(entry.rows) || entry.rows.length === 0) ? '' : state.history.map((entry) => { const pair = `${entry.pair.from}/${entry.pair.to}`; return (Array.isArray(entry.rows) ? entry.rows.slice(0, 4) : []).map((row, index) => ` `).join(''); }).join('')}
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

Letzte gespeicherte Snapshots

${state.recentFetches.length === 0 ? '' : state.recentFetches.map((fetch) => ` `).join('')}
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')}
`; } function renderCurrencies() { const settings = currentSettings(); const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : []; return `

Katalog

Bevorzugte Waehrungen

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

${catalog.length === 0 ? 'Noch kein Katalog geladen' : catalog.map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.name)}`).join('')}
`; } function renderSettings() { const settings = currentSettings(); return `

Provider

Externe Quelle und Token

Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.

`; } function renderMain() { const current = sectionMap.get(state.activeTab) || sections[0]; let content = ''; if (state.activeTab === 'currencies') { content = renderCurrencies(); } else if (state.activeTab === 'settings') { content = renderSettings(); } else { content = renderOverview(); } return `

Waehrungs-Checker

${escapeHtml(current.label)}

${escapeHtml(current.summary)}

API aktiv Widget optional Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}
${content}
`; } function bind(rootNode) { rootNode.querySelectorAll('[data-action="tab"]').forEach((button) => { button.addEventListener('click', () => { setState({ activeTab: String(button.getAttribute('data-tab') || 'overview') }); }); }); rootNode.querySelectorAll('[data-action="refresh"]').forEach((button) => { button.addEventListener('click', () => runRefresh(false)); }); rootNode.querySelectorAll('[data-action="refresh-force"]').forEach((button) => { button.addEventListener('click', () => { const confirmed = window.confirm('Der Abruf wird jetzt unabhaengig vom Alter der gespeicherten Kurse erzwungen. Wirklich fortfahren?'); if (confirmed) { runRefresh(true); } }); }); rootNode.querySelectorAll('[data-action="sync-catalog"]').forEach((button) => { button.addEventListener('click', () => syncCatalog()); }); rootNode.querySelectorAll('[data-action="open-widget-setup"]').forEach((button) => { button.addEventListener('click', () => openWidgetSetup()); }); rootNode.querySelectorAll('[data-form="convert"]').forEach((form) => { form.addEventListener('submit', runConversion); }); rootNode.querySelectorAll('[data-form="settings"]').forEach((form) => { form.addEventListener('submit', saveSettings); }); } function render() { if (state.loading) { root.innerHTML = '

Waehrungsdaten werden geladen.

'; return; } if (state.error && !state.settings) { root.innerHTML = `

${escapeHtml(state.error)}

`; return; } root.innerHTML = `
${renderNav()} ${renderMain()}
`; bind(root); } render(); loadBootstrap(); } window.initFxRatesApp = function initFxRatesApp(rootNode, options) { const root = rootNode || document.getElementById('fx-rates-app'); if (!root || root.dataset.moduleInitialized === '1') { return; } root.dataset.moduleInitialized = '1'; createFxRatesApp(root, options && typeof options === 'object' ? options : {}); }; if (document.getElementById('fx-rates-app')) { window.initFxRatesApp(document.getElementById('fx-rates-app'), {}); } })();