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([
-
-
= module_shell_footer() ?>