adsasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-29 00:58:31 +02:00
parent 74b1651eac
commit f1b41e07cb
6 changed files with 336 additions and 169 deletions

View File

@@ -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;
}

View File

@@ -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 = '<tr><td colspan="2">Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.</td></tr>';
return;
}
nodes.ratesBody.innerHTML = entries.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 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');
});
})();

View File

@@ -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"] }
]
}

View File

@@ -1,6 +1,6 @@
{ {
"title": "Waehrungskurse", "title": "Waehrungskurse",
"version": "0.1.0", "version": "0.1.1",
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.", "description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
"enabled_by_default": true, "enabled_by_default": true,
"setup": { "setup": {

View File

@@ -0,0 +1,30 @@
<?php
$file = (string) ($_GET['file'] ?? '');
$base = realpath(__DIR__ . '/../assets');
$map = [
'fx-rates.css' => $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;

View File

@@ -3,6 +3,12 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php'; 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'); $settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service'); $service = module_fn('fx-rates', 'service');
$latest = $service->latestStatus(); $latest = $service->latestStatus();
@@ -77,172 +83,4 @@ $pageData = json_encode([
</div> </div>
</div> </div>
</div> </div>
<style>
#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,.12); border-radius: 8px; padding: 1rem; }
.fx-card h1, .fx-card h2 { margin: 0 0 .5rem; }
.fx-card p { margin: 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: .75rem; flex-wrap: wrap; }
.fx-button { appearance: none; border: 1px solid #d0d7e2; background: #fff; color: #1c2734; border-radius: 8px; padding: .7rem 1rem; cursor: pointer; }
.fx-button--primary { background: #1c2734; color: #fff; border-color: #1c2734; }
.fx-button[disabled] { opacity: .6; cursor: wait; }
.fx-meta-grid, .fx-form-grid { display: grid; gap: .75rem; }
.fx-meta-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin-top: .75rem; }
.fx-form-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.fx-form-grid label, .fx-block { display: grid; gap: .35rem; }
.fx-form-grid span, .fx-block span { font-size: .9rem; color: #5b6573; }
.fx-form-grid input, .fx-block input { width: 100%; border: 1px solid #d0d7e2; border-radius: 8px; padding: .7rem .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: .65rem .4rem; }
.fx-api-note { margin-top: .75rem; font-size: .95rem; }
</style>
<script>
(() => {
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';
let loading = false;
const setMessage = (text, type = '') => {
if (!nodes.message) return;
nodes.message.textContent = text || '';
nodes.message.className = `fx-message${type ? ` is-${type}` : ''}`;
};
const setLoading = (state) => {
loading = state;
root.querySelectorAll('button[data-action]').forEach((button) => {
button.disabled = state;
});
};
const parsePreferredCurrencies = () => {
return 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 = '<tr><td colspan="2">Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.</td></tr>';
return;
}
nodes.ratesBody.innerHTML = entries.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 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');
});
})();
</script>
<?= module_shell_footer() ?> <?= module_shell_footer() ?>