diff --git a/modules/fx-rates/assets/fx-rates.css b/modules/fx-rates/assets/fx-rates.css new file mode 100644 index 0000000..9449f9e --- /dev/null +++ b/modules/fx-rates/assets/fx-rates.css @@ -0,0 +1,129 @@ +#fx-rates-app { + padding: 1rem 0 2rem; +} + +.fx-stack { + display: grid; + gap: 1rem; +} + +.fx-card { + background: var(--panel-bg, #fff); + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 8px; + padding: 1rem; +} + +.fx-card h1, +.fx-card h2 { + margin: 0 0 0.5rem; +} + +.fx-card p { + margin: 0 0 0.75rem; + color: #5b6573; +} + +.fx-card-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.fx-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.fx-button { + appearance: none; + border: 1px solid #d0d7e2; + background: #fff; + color: #1c2734; + border-radius: 8px; + padding: 0.7rem 1rem; + cursor: pointer; +} + +.fx-button--primary { + background: #1c2734; + color: #fff; + border-color: #1c2734; +} + +.fx-button[disabled] { + opacity: 0.6; + cursor: wait; +} + +.fx-meta-grid, +.fx-form-grid { + display: grid; + gap: 0.75rem; +} + +.fx-meta-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 0.75rem; +} + +.fx-form-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.fx-form-grid label, +.fx-block { + display: grid; + gap: 0.35rem; +} + +.fx-form-grid span, +.fx-block span { + font-size: 0.9rem; + color: #5b6573; +} + +.fx-form-grid input, +.fx-block input { + width: 100%; + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 0.7rem 0.8rem; +} + +.fx-message { + min-height: 1.5rem; + color: #1c2734; +} + +.fx-message.is-error { + color: #b42318; +} + +.fx-message.is-success { + color: #027a48; +} + +.fx-table-wrap { + overflow-x: auto; +} + +.fx-table { + width: 100%; + border-collapse: collapse; +} + +.fx-table th, +.fx-table td { + text-align: left; + border-bottom: 1px solid #eef2f6; + padding: 0.65rem 0.4rem; +} + +.fx-api-note { + margin-top: 0.75rem; + font-size: 0.95rem; +} diff --git a/modules/fx-rates/assets/fx-rates.js b/modules/fx-rates/assets/fx-rates.js new file mode 100644 index 0000000..2ed0def --- /dev/null +++ b/modules/fx-rates/assets/fx-rates.js @@ -0,0 +1,159 @@ +(() => { + const root = document.getElementById('fx-rates-app'); + if (!root) { + return; + } + + const page = JSON.parse(root.dataset.page || '{}'); + const settings = page.settings || {}; + const nodes = { + message: root.querySelector('[data-bind="message"]'), + lastFetch: root.querySelector('[data-bind="last-fetch"]'), + defaultBase: root.querySelector('[data-bind="default-base"]'), + displayBase: root.querySelector('[data-bind="display-base"]'), + ratesBody: root.querySelector('[data-bind="rates-body"]'), + defaultBaseInput: root.querySelector('input[name="default_base_currency"]'), + displayBaseInput: root.querySelector('input[name="display_base_currency"]'), + preferredCurrenciesInput: root.querySelector('input[name="preferred_currencies"]'), + }; + const apiBase = '/api/fx-rates/v1'; + + const setMessage = (text, type = '') => { + if (!nodes.message) { + return; + } + nodes.message.textContent = text || ''; + nodes.message.className = `fx-message${type ? ` is-${type}` : ''}`; + }; + + const setLoading = (state) => { + root.querySelectorAll('button[data-action]').forEach((button) => { + button.disabled = state; + }); + }; + + const parsePreferredCurrencies = () => String(nodes.preferredCurrenciesInput?.value || '') + .split(/[\s,;]+/) + .map((item) => item.trim().toUpperCase()) + .filter(Boolean); + + const renderSnapshot = (snapshot) => { + const rates = snapshot && snapshot.rates ? snapshot.rates : null; + const entries = rates ? Object.entries(rates) : []; + if (!nodes.ratesBody) { + return; + } + + if (!entries.length) { + nodes.ratesBody.innerHTML = 'Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.'; + return; + } + + nodes.ratesBody.innerHTML = entries.map(([code, rate]) => { + const formatted = typeof rate === 'number' + ? rate.toLocaleString('de-DE', { maximumFractionDigits: 8 }) + : 'n/a'; + return `${code}${formatted}`; + }).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( + nodes.displayBaseInput?.value || settings.display_base_currency || settings.default_base_currency || 'EUR' + ).trim().toUpperCase(); + const preferred = parsePreferredCurrencies(); + const query = new URLSearchParams(); + query.set('base', base); + if (preferred.length) { + query.set('symbols', preferred.join(',')); + } + + const data = await request(`/latest?${query.toString()}`); + renderSnapshot(data); + if (nodes.lastFetch) { + nodes.lastFetch.textContent = data?.fetched_at || 'noch keiner'; + } + if (nodes.displayBase) { + nodes.displayBase.textContent = base; + } + return data; + }; + + root.querySelector('[data-action="refresh-rates"]')?.addEventListener('click', async () => { + try { + setLoading(true); + setMessage('Ich rufe jetzt die aktuellen Kurse ab.'); + const payload = { + force: true, + base: String(nodes.defaultBaseInput?.value || settings.default_base_currency || 'EUR').trim().toUpperCase(), + currencies: parsePreferredCurrencies(), + }; + const data = await request('/refresh', { method: 'POST', body: JSON.stringify(payload) }); + setMessage(`Aktuelle Kurse gespeichert. ${data?.updated_count || 0} Werte aktualisiert.`, 'success'); + await loadLatest(); + } catch (error) { + setMessage(error.message || 'Kurse konnten nicht aktualisiert werden.', 'error'); + } finally { + setLoading(false); + } + }); + + root.querySelector('[data-action="refresh-catalog"]')?.addEventListener('click', async () => { + try { + setLoading(true); + setMessage('Ich synchronisiere jetzt den Waehrungskatalog.'); + const data = await request('/currencies-refresh', { method: 'POST', body: JSON.stringify({}) }); + setMessage(`Waehrungskatalog synchronisiert. ${data?.synced_count || 0} Waehrungen verarbeitet.`, 'success'); + } catch (error) { + setMessage(error.message || 'Waehrungskatalog konnte nicht synchronisiert werden.', 'error'); + } finally { + setLoading(false); + } + }); + + root.querySelector('[data-action="save-settings"]')?.addEventListener('click', async () => { + try { + setLoading(true); + setMessage('Ich speichere jetzt die Waehrungs-Auswahl.'); + const payload = { + default_base_currency: String(nodes.defaultBaseInput?.value || '').trim().toUpperCase(), + display_base_currency: String(nodes.displayBaseInput?.value || '').trim().toUpperCase(), + preferred_currencies: parsePreferredCurrencies(), + }; + const data = await request('/settings', { method: 'PUT', body: JSON.stringify(payload) }); + if (nodes.defaultBase) { + nodes.defaultBase.textContent = data?.default_base_currency || ''; + } + if (nodes.displayBase) { + nodes.displayBase.textContent = data?.display_base_currency || ''; + } + setMessage('Waehrungs-Auswahl gespeichert.', 'success'); + await loadLatest(); + } catch (error) { + setMessage(error.message || 'Waehrungs-Auswahl konnte nicht gespeichert werden.', 'error'); + } finally { + setLoading(false); + } + }); + + loadLatest().catch((error) => { + setMessage(error.message || 'Letzter Snapshot konnte nicht geladen werden.', 'error'); + }); +})(); diff --git a/modules/fx-rates/design.json b/modules/fx-rates/design.json new file mode 100644 index 0000000..aea9622 --- /dev/null +++ b/modules/fx-rates/design.json @@ -0,0 +1,11 @@ +{ + "eyebrow": "Modul", + "title": "FX-Rates", + "description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.", + "actions": [ + { "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" } + ], + "tabs": [ + { "label": "Ueberblick", "href": "/module/fx-rates", "match_prefixes": ["/module/fx-rates"] } + ] +} diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json index db563cc..a1ac54a 100644 --- a/modules/fx-rates/module.json +++ b/modules/fx-rates/module.json @@ -1,6 +1,6 @@ { "title": "Waehrungskurse", - "version": "0.1.0", + "version": "0.1.1", "description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.", "enabled_by_default": true, "setup": { diff --git a/modules/fx-rates/pages/asset.php b/modules/fx-rates/pages/asset.php new file mode 100644 index 0000000..2b5fd6b --- /dev/null +++ b/modules/fx-rates/pages/asset.php @@ -0,0 +1,30 @@ + $base . '/fx-rates.css', + 'fx-rates.js' => $base . '/fx-rates.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/modules/fx-rates/pages/index.php b/modules/fx-rates/pages/index.php index 44dc12a..053b4c4 100644 --- a/modules/fx-rates/pages/index.php +++ b/modules/fx-rates/pages/index.php @@ -3,6 +3,12 @@ declare(strict_types=1); require_once dirname(__DIR__) . '/bootstrap.php'; +$assets = app()->assets(); +if ($assets) { + $assets->addStyle('/module/fx-rates/asset?file=fx-rates.css'); + $assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true); +} + $settings = module_fn('fx-rates', 'settings'); $service = module_fn('fx-rates', 'service'); $latest = $service->latestStatus(); @@ -77,172 +83,4 @@ $pageData = json_encode([ - -