781 lines
30 KiB
JavaScript
781 lines
30 KiB
JavaScript
(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';
|
|
}
|
|
|
|
let normalized = raw;
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
normalized = `${raw}T00:00:00Z`;
|
|
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
|
normalized = `${raw.replace(' ', 'T')}Z`;
|
|
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
|
normalized = `${raw}Z`;
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
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 }));
|
|
}
|
|
|
|
function normalizedTimestamp(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return '';
|
|
}
|
|
|
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
|
return `${raw.replace(' ', 'T')}Z`;
|
|
}
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
|
return `${raw}Z`;
|
|
}
|
|
|
|
return raw;
|
|
}
|
|
|
|
function buildHistoryMatrix(historyEntries) {
|
|
const columns = [];
|
|
const rowsByKey = new Map();
|
|
|
|
historyEntries.forEach((entry) => {
|
|
const pair = entry && entry.pair ? entry.pair : null;
|
|
const columnKey = pair ? `${String(pair.from || '').toUpperCase()}/${String(pair.to || '').toUpperCase()}` : '';
|
|
if (!columnKey) {
|
|
return;
|
|
}
|
|
if (!columns.includes(columnKey)) {
|
|
columns.push(columnKey);
|
|
}
|
|
|
|
(Array.isArray(entry.rows) ? entry.rows : []).forEach((row) => {
|
|
const fetchId = Number(row && row.fetch_id);
|
|
const fetchedAt = normalizedTimestamp(row && row.fetched_at);
|
|
const rowKey = fetchId > 0 ? `fetch:${fetchId}` : `time:${fetchedAt}`;
|
|
if (!rowKey || rowKey === 'time:') {
|
|
return;
|
|
}
|
|
|
|
if (!rowsByKey.has(rowKey)) {
|
|
rowsByKey.set(rowKey, {
|
|
key: rowKey,
|
|
fetch_id: fetchId > 0 ? fetchId : null,
|
|
fetched_at: row && row.fetched_at ? String(row.fetched_at) : '',
|
|
provider: row && row.provider ? String(row.provider) : '',
|
|
values: {},
|
|
});
|
|
}
|
|
|
|
const bucket = rowsByKey.get(rowKey);
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
|
|
if (!bucket.fetched_at && row && row.fetched_at) {
|
|
bucket.fetched_at = String(row.fetched_at);
|
|
}
|
|
if (!bucket.provider && row && row.provider) {
|
|
bucket.provider = String(row.provider);
|
|
}
|
|
bucket.values[columnKey] = Number.isFinite(Number(row && row.rate)) ? Number(row.rate) : null;
|
|
});
|
|
});
|
|
|
|
const rows = Array.from(rowsByKey.values()).sort((left, right) => {
|
|
const leftTs = new Date(normalizedTimestamp(left.fetched_at) || 0).getTime();
|
|
const rightTs = new Date(normalizedTimestamp(right.fetched_at) || 0).getTime();
|
|
return rightTs - leftTs;
|
|
});
|
|
|
|
return {
|
|
columns,
|
|
rows,
|
|
};
|
|
}
|
|
|
|
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 `
|
|
<aside class="window-app-sidebar">
|
|
<div class="window-app-brand">
|
|
<p class="window-app-kicker">Modul</p>
|
|
<h1>Waehrungs-Checker</h1>
|
|
<p>Waehrungskurse, Historie, API und Widget-Aktualisierung in einer gemeinsamen Modulanwendung.</p>
|
|
</div>
|
|
<div class="window-app-nav-list">
|
|
${sections.map((section) => `
|
|
<button class="${cx('window-app-nav-button', state.activeTab === section.key && 'is-active')}" type="button" data-action="tab" data-tab="${escapeHtml(section.key)}">
|
|
<strong>${escapeHtml(section.label)}</strong>
|
|
<span class="window-app-meta">${escapeHtml(section.summary)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
</aside>
|
|
`;
|
|
}
|
|
|
|
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 : [];
|
|
const historyMatrix = buildHistoryMatrix(state.history);
|
|
|
|
return `
|
|
<div class="fx-stack">
|
|
<section class="window-app-card">
|
|
<p class="window-app-kicker">Aktualisierung</p>
|
|
<h3>Externer Kursabruf</h3>
|
|
<p class="window-app-copy">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.</p>
|
|
<div class="fx-inline-actions">
|
|
<button class="fx-button" type="button" data-action="refresh">${state.saving ? 'Aktualisiert ...' : 'Kurse aktualisieren'}</button>
|
|
<button class="fx-button fx-button--ghost" type="button" data-action="refresh-force">${state.saving ? 'Aktualisiert ...' : 'Erzwungen aktualisieren'}</button>
|
|
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Widget-Auswahl oeffnen</button>
|
|
</div>
|
|
<p class="${cx('fx-message', state.error && 'is-error', state.message && !state.error && 'is-success')}">${escapeHtml(state.error || state.message || '')}</p>
|
|
</section>
|
|
|
|
<section class="fx-cards">
|
|
<article class="fx-card">
|
|
<p class="fx-kicker">Letzter Abruf</p>
|
|
<div class="fx-stat-value">${escapeHtml(latest ? fmtDate(latest.fetched_at) : 'Noch keiner')}</div>
|
|
<p class="fx-copy">${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}</p>
|
|
</article>
|
|
<article class="fx-card">
|
|
<p class="fx-kicker">Standard-Basis</p>
|
|
<div class="fx-stat-value">${escapeHtml(base)}</div>
|
|
<p class="fx-copy">Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}</p>
|
|
</article>
|
|
<article class="fx-card">
|
|
<p class="fx-kicker">Bevorzugte Waehrungen</p>
|
|
<div class="fx-stat-value">${escapeHtml(String(preferred.length || 0))}</div>
|
|
<p class="fx-copy">${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="fx-table-grid">
|
|
<section class="fx-panel">
|
|
<p class="fx-kicker">Umrechnung</p>
|
|
<h3>Direkter Kursvergleich</h3>
|
|
<form class="fx-form-grid" data-form="convert">
|
|
<div class="fx-field">
|
|
<label for="fx-amount">Betrag</label>
|
|
<input id="fx-amount" class="fx-input" type="number" name="amount" min="0" step="0.00000001" value="1">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-from">Von</label>
|
|
<select id="fx-from" class="fx-select" name="from">
|
|
${preferred.map((currency) => `<option value="${escapeHtml(currency)}">${escapeHtml(currency)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-to">Nach</label>
|
|
<select id="fx-to" class="fx-select" name="to">
|
|
${preferred.map((currency, index) => `<option value="${escapeHtml(currency)}" ${index === 1 ? 'selected' : ''}>${escapeHtml(currency)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="fx-form-actions">
|
|
<button class="fx-button" type="submit">Umrechnen</button>
|
|
</div>
|
|
</form>
|
|
<p class="fx-copy">${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.'}</p>
|
|
</section>
|
|
<section class="fx-panel">
|
|
<p class="fx-kicker">Widget</p>
|
|
<h3>Desktop-Infobereich</h3>
|
|
<div class="fx-widget-note">
|
|
<p class="fx-copy">Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.</p>
|
|
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Setup oeffnen</button>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="fx-panel">
|
|
<p class="fx-kicker">Verlauf</p>
|
|
<h3>Neueste Kursverlaeufe</h3>
|
|
<div class="fx-table-wrap">
|
|
<table class="fx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Zeit</th>
|
|
<th>Quelle</th>
|
|
${historyMatrix.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${historyMatrix.rows.length === 0
|
|
? `<tr><td colspan="${String(2 + historyMatrix.columns.length)}">Noch keine Verlaufsdaten vorhanden.</td></tr>`
|
|
: historyMatrix.rows.map((row) => `
|
|
<tr>
|
|
<td>${escapeHtml(fmtDate(row.fetched_at))}</td>
|
|
<td>${escapeHtml(row.provider || 'n/a')}</td>
|
|
${historyMatrix.columns.map((column) => `<td>${escapeHtml(fmtNumber(row.values[column], 8))}</td>`).join('')}
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="fx-panel">
|
|
<p class="fx-kicker">Abrufe</p>
|
|
<h3>Letzte gespeicherte Snapshots</h3>
|
|
<div class="fx-table-wrap">
|
|
<table class="fx-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Zeit</th>
|
|
<th>Basis</th>
|
|
<th>Provider</th>
|
|
<th>Ausloeser</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${state.recentFetches.length === 0
|
|
? '<tr><td colspan="4">Noch keine Abrufe gespeichert.</td></tr>'
|
|
: state.recentFetches.map((fetch) => `
|
|
<tr>
|
|
<td>${escapeHtml(fmtDate(fetch.fetched_at))}</td>
|
|
<td>${escapeHtml(fetch.base_currency || 'n/a')}</td>
|
|
<td>${escapeHtml(fetch.provider || 'n/a')}</td>
|
|
<td>${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderCurrencies() {
|
|
const settings = currentSettings();
|
|
const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : [];
|
|
return `
|
|
<div class="fx-stack">
|
|
<section class="window-app-card">
|
|
<p class="window-app-kicker">Katalog</p>
|
|
<h3>Bevorzugte Waehrungen</h3>
|
|
<p class="window-app-copy">Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.</p>
|
|
<div class="fx-inline-actions">
|
|
<button class="fx-button" type="button" data-action="sync-catalog">${state.syncingCatalog ? 'Synchronisiert ...' : 'Waehrungskatalog synchronisieren'}</button>
|
|
</div>
|
|
<p class="fx-copy">Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}</p>
|
|
</section>
|
|
|
|
<form class="fx-panel fx-stack" data-form="settings">
|
|
<div class="fx-form-grid">
|
|
<div class="fx-field">
|
|
<label for="fx-display-base">Anzeige-Basis</label>
|
|
<input id="fx-display-base" class="fx-input" type="text" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-default-base">Standard-Basis</label>
|
|
<input id="fx-default-base" class="fx-input" type="text" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-preferred">Bevorzugte Waehrungen</label>
|
|
<input id="fx-preferred" class="fx-input" type="text" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
|
</div>
|
|
</div>
|
|
<div class="fx-form-actions">
|
|
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Waehrungen speichern'}</button>
|
|
</div>
|
|
</form>
|
|
|
|
<section class="fx-panel">
|
|
<p class="fx-kicker">Verfuegbarer Katalog</p>
|
|
<div class="fx-token-row">
|
|
${catalog.length === 0
|
|
? '<span class="fx-token">Noch kein Katalog geladen</span>'
|
|
: catalog.map((item) => `<span class="fx-token">${escapeHtml(item.code)} · ${escapeHtml(item.name)}</span>`).join('')}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSettings() {
|
|
const settings = currentSettings();
|
|
return `
|
|
<form class="fx-stack" data-form="settings">
|
|
<section class="window-app-card">
|
|
<p class="window-app-kicker">Provider</p>
|
|
<h3>Externe Quelle und Token</h3>
|
|
<div class="fx-form-grid">
|
|
<div class="fx-field">
|
|
<label for="fx-provider">Provider</label>
|
|
<input id="fx-provider" class="fx-input" type="text" name="provider" value="${escapeHtml(settings.provider || 'currencyapi')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-api-version">API Version</label>
|
|
<select id="fx-api-version" class="fx-select" name="api_version">
|
|
<option value="v2" ${settings.api_version === 'v2' ? 'selected' : ''}>v2</option>
|
|
<option value="v3" ${settings.api_version === 'v3' ? 'selected' : ''}>v3</option>
|
|
</select>
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-api-url">API URL</label>
|
|
<input id="fx-api-url" class="fx-input" type="text" name="api_url" value="${escapeHtml(settings.api_url || '')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-currencies-url">Currencies URL</label>
|
|
<input id="fx-currencies-url" class="fx-input" type="text" name="currencies_url" value="${escapeHtml(settings.currencies_url || settings.api_url || '')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-api-key">API Key</label>
|
|
<input id="fx-api-key" class="fx-input" type="password" name="api_key" value="${escapeHtml(settings.api_key || '')}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-timeout">Timeout (Sek.)</label>
|
|
<input id="fx-timeout" class="fx-input" type="number" name="timeout_sec" min="2" step="1" value="${escapeHtml(String(settings.timeout_sec || 10))}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-refresh-hours">Refresh-Sperre (Std.)</label>
|
|
<input id="fx-refresh-hours" class="fx-input" type="number" name="refresh_max_age_hours" min="1" step="0.5" value="${escapeHtml(String(settings.refresh_max_age_hours || 6))}">
|
|
</div>
|
|
<div class="fx-field">
|
|
<label for="fx-timezone">Zeitzone</label>
|
|
<input id="fx-timezone" class="fx-input" type="text" name="schedule_timezone" value="${escapeHtml(settings.schedule_timezone || 'Europe/Berlin')}">
|
|
</div>
|
|
<input type="hidden" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
|
<input type="hidden" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
|
<input type="hidden" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
|
</div>
|
|
<div class="fx-form-actions">
|
|
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Setup speichern'}</button>
|
|
</div>
|
|
<p class="fx-copy">Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.</p>
|
|
</section>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<main class="window-app-main">
|
|
<section class="window-app-hero">
|
|
<div>
|
|
<p class="window-app-kicker">Waehrungs-Checker</p>
|
|
<h2 class="window-app-title">${escapeHtml(current.label)}</h2>
|
|
<p class="window-app-copy">${escapeHtml(current.summary)}</p>
|
|
</div>
|
|
<div class="window-app-pill-row">
|
|
<span class="window-app-pill">API aktiv</span>
|
|
<span class="window-app-pill">Widget optional</span>
|
|
<span class="window-app-pill">Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}</span>
|
|
</div>
|
|
</section>
|
|
<div class="window-app-panel fx-shell">
|
|
${content}
|
|
</div>
|
|
</main>
|
|
`;
|
|
}
|
|
|
|
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 = '<div class="window-app-loading"><p class="window-app-copy">Waehrungsdaten werden geladen.</p></div>';
|
|
return;
|
|
}
|
|
|
|
if (state.error && !state.settings) {
|
|
root.innerHTML = `<div class="window-app-error-state"><p class="window-app-message is-error">${escapeHtml(state.error)}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
root.innerHTML = `
|
|
<div class="window-app-shell">
|
|
<div class="window-app-frame">
|
|
${renderNav()}
|
|
${renderMain()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
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'), {});
|
|
}
|
|
})();
|