238 lines
8.2 KiB
JavaScript
238 lines
8.2 KiB
JavaScript
(() => {
|
||
const root = document.getElementById('fx-rates-app');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
|
||
const page = JSON.parse(root.dataset.page || '{}');
|
||
const settings = page.settings || {};
|
||
const nodes = {
|
||
ratesBody: root.querySelector('[data-bind="rates-body"]'),
|
||
historyHead: root.querySelector('[data-bind="history-head"]'),
|
||
historyBody: root.querySelector('[data-bind="history-body"]'),
|
||
fetchesBody: root.querySelector('[data-bind="fetches-body"]'),
|
||
convertResult: root.querySelector('[data-bind="convert-result"]'),
|
||
convertFrom: root.querySelector('select[name="convert_from"]'),
|
||
convertTo: root.querySelector('select[name="convert_to"]'),
|
||
convertAmount: root.querySelector('input[name="convert_amount"]'),
|
||
};
|
||
const apiBase = '/api/fx-rates/v1';
|
||
const preferredCurrencies = Array.isArray(page.preferred_currencies)
|
||
? page.preferred_currencies
|
||
.map((item) => String(item || '').trim().toUpperCase())
|
||
.filter(Boolean)
|
||
: [];
|
||
|
||
const renderSnapshot = (snapshot) => {
|
||
const rates = snapshot && snapshot.rates ? snapshot.rates : null;
|
||
const entries = rates ? Object.entries(rates) : [];
|
||
const visibleCurrencies = preferredCurrencies.length
|
||
? new Set(preferredCurrencies)
|
||
: null;
|
||
if (!nodes.ratesBody) {
|
||
return;
|
||
}
|
||
|
||
const filteredEntries = visibleCurrencies
|
||
? entries.filter(([code]) => visibleCurrencies.has(String(code || '').trim().toUpperCase()))
|
||
: entries;
|
||
|
||
if (!filteredEntries.length) {
|
||
nodes.ratesBody.innerHTML = '<tr><td colspan="2">Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
nodes.ratesBody.innerHTML = filteredEntries.map(([code, rate]) => {
|
||
const formatted = typeof rate === 'number'
|
||
? rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })
|
||
: 'n/a';
|
||
return `<tr><td>${code}</td><td>${formatted}</td></tr>`;
|
||
}).join('');
|
||
};
|
||
|
||
const renderFetches = (fetches) => {
|
||
if (!nodes.fetchesBody) {
|
||
return;
|
||
}
|
||
const entries = Array.isArray(fetches) ? fetches : [];
|
||
if (!entries.length) {
|
||
nodes.fetchesBody.innerHTML = '<tr><td colspan="3">Noch keine Abrufe vorhanden.</td></tr>';
|
||
return;
|
||
}
|
||
nodes.fetchesBody.innerHTML = entries.map((entry) => `
|
||
<tr>
|
||
<td>${entry?.fetched_at_display || entry?.fetched_at || ''}</td>
|
||
<td>${entry?.base_currency || ''}</td>
|
||
<td>${entry?.provider || ''}</td>
|
||
</tr>
|
||
`).join('');
|
||
};
|
||
|
||
const renderHistory = (rows, currencies) => {
|
||
if (!nodes.historyHead || !nodes.historyBody) {
|
||
return;
|
||
}
|
||
|
||
const series = Array.isArray(currencies) ? currencies : [];
|
||
if (!series.length) {
|
||
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
|
||
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
nodes.historyHead.innerHTML = `
|
||
<tr>
|
||
<th>Datum</th>
|
||
${series.map((currency) => `<th>${currency}</th>`).join('')}
|
||
</tr>
|
||
`;
|
||
|
||
const entries = Array.isArray(rows) ? rows : [];
|
||
if (!entries.length) {
|
||
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
nodes.historyBody.innerHTML = entries.map((entry) => `
|
||
<tr>
|
||
<td>${entry.label}</td>
|
||
${series.map((currency) => {
|
||
const value = entry.rates?.[currency];
|
||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||
return '<td>–</td>';
|
||
}
|
||
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
|
||
}).join('')}
|
||
</tr>
|
||
`).join('');
|
||
};
|
||
|
||
const request = async (path, options = {}) => {
|
||
const response = await fetch(`${apiBase}${path}`, {
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||
...(options.headers || {}),
|
||
},
|
||
...options,
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
return payload.data;
|
||
};
|
||
|
||
const loadLatest = async () => {
|
||
const base = String(
|
||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||
).trim().toUpperCase();
|
||
const query = new URLSearchParams();
|
||
query.set('base', base);
|
||
if (preferredCurrencies.length) {
|
||
query.set('symbols', preferredCurrencies.join(','));
|
||
}
|
||
|
||
const data = await request(`/latest?${query.toString()}`);
|
||
renderSnapshot(data);
|
||
return data;
|
||
};
|
||
|
||
const loadHistory = async () => {
|
||
const base = String(
|
||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||
).trim().toUpperCase();
|
||
const historyCurrencies = preferredCurrencies.filter((currency) => currency !== base);
|
||
|
||
if (!historyCurrencies.length) {
|
||
renderHistory([], []);
|
||
return;
|
||
}
|
||
|
||
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
|
||
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
|
||
const rows = await request(`/history?${query.toString()}`);
|
||
return { currency, rows: Array.isArray(rows) ? rows : [] };
|
||
}));
|
||
|
||
const byDate = new Map();
|
||
histories.forEach(({ currency, rows }) => {
|
||
rows.forEach((row) => {
|
||
const key = String(row?.fetched_at || row?.rate_date || '').trim();
|
||
if (!key) {
|
||
return;
|
||
}
|
||
if (!byDate.has(key)) {
|
||
byDate.set(key, {
|
||
sortKey: key,
|
||
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
|
||
rates: {},
|
||
});
|
||
}
|
||
const entry = byDate.get(key);
|
||
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
|
||
entry.rates[currency] = row.rate;
|
||
}
|
||
});
|
||
});
|
||
|
||
const mergedRows = Array.from(byDate.values())
|
||
.sort((left, right) => String(left.sortKey).localeCompare(String(right.sortKey)));
|
||
|
||
renderHistory(mergedRows, historyCurrencies);
|
||
};
|
||
|
||
const calculateConversion = async () => {
|
||
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
|
||
return;
|
||
}
|
||
|
||
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
|
||
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
|
||
const amount = Number(nodes.convertAmount.value || '0');
|
||
|
||
if (!from || !to || !Number.isFinite(amount)) {
|
||
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
|
||
return;
|
||
}
|
||
|
||
if (from === to) {
|
||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const query = new URLSearchParams({ from, to });
|
||
const data = await request(`/rate?${query.toString()}`);
|
||
const rate = Number(data?.rate || 0);
|
||
if (!Number.isFinite(rate) || rate <= 0) {
|
||
throw new Error('Kein Kurs verfuegbar.');
|
||
}
|
||
const converted = amount * rate;
|
||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
|
||
} catch (error) {
|
||
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
|
||
}
|
||
};
|
||
|
||
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
|
||
node?.addEventListener('change', () => {
|
||
calculateConversion().catch(() => {});
|
||
});
|
||
node?.addEventListener('input', () => {
|
||
calculateConversion().catch(() => {});
|
||
});
|
||
});
|
||
|
||
renderFetches(page.recent_fetches || []);
|
||
|
||
loadLatest().catch(() => {});
|
||
loadHistory().catch(() => {
|
||
renderHistory([], preferredCurrencies.filter((currency) => currency !== String(
|
||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||
).trim().toUpperCase()));
|
||
});
|
||
calculateConversion().catch(() => {});
|
||
})();
|