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

This commit is contained in:
2026-05-02 02:56:00 +02:00
parent aa30feba85
commit b9f248aae0
6 changed files with 514 additions and 54 deletions

View File

@@ -0,0 +1,158 @@
(() => {
const root = document.getElementById('fx-rates-currencies');
if (!root) {
return;
}
const page = JSON.parse(root.dataset.page || '{}');
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
const selected = new Set(
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
.map((code) => String(code || '').trim().toUpperCase())
.filter(Boolean)
);
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
const nodes = {
tokenList: root.querySelector('[data-fx-token-list]'),
searchInput: root.querySelector('[data-fx-search-input]'),
suggestions: root.querySelector('[data-fx-suggestions]'),
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const currencyByCode = new Map(
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
);
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
const ensureDisplayBase = () => {
const available = sortedSelectedCodes();
if (available.length === 0) {
displayBase = '';
return;
}
if (!displayBase || !selected.has(displayBase)) {
displayBase = available[0];
}
};
const renderHiddenInputs = () => {
if (!nodes.hiddenPreferred) {
return;
}
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
.join('');
if (nodes.displayBaseHidden) {
nodes.displayBaseHidden.value = displayBase;
}
};
const renderDisplayBase = () => {
if (!nodes.displayBaseSelect) {
return;
}
ensureDisplayBase();
const available = sortedSelectedCodes();
nodes.displayBaseSelect.innerHTML = available.length
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
nodes.displayBaseSelect.disabled = available.length === 0;
};
const removeCode = (code) => {
selected.delete(code);
renderAll();
};
const renderTokens = () => {
if (!nodes.tokenList) {
return;
}
const selectedCodes = sortedSelectedCodes();
if (selectedCodes.length === 0) {
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
return;
}
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
const currency = currencyByCode.get(code) || { code, name: code };
return `
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
<span class="fx-token-close">x</span>
</button>
`;
}).join('');
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
button.addEventListener('click', () => {
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
});
});
};
const renderSuggestions = () => {
if (!nodes.suggestions || !nodes.searchInput) {
return;
}
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
if (!needle) {
nodes.suggestions.innerHTML = '';
return;
}
const matches = currencies
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
.filter((currency) => {
const code = String(currency.code || '').toLowerCase();
const name = String(currency.name || '').toLowerCase();
return code.includes(needle) || name.includes(needle);
})
.slice(0, 12);
nodes.suggestions.innerHTML = matches.map((currency) => `
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
<span>${escapeHtml(String(currency.name || ''))}</span>
</button>
`).join('');
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
button.addEventListener('click', () => {
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
if (code) {
selected.add(code);
if (nodes.searchInput) {
nodes.searchInput.value = '';
}
renderAll();
}
});
});
};
const renderAll = () => {
ensureDisplayBase();
renderTokens();
renderDisplayBase();
renderHiddenInputs();
renderSuggestions();
};
nodes.searchInput?.addEventListener('input', renderSuggestions);
nodes.displayBaseSelect?.addEventListener('change', () => {
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
renderHiddenInputs();
});
renderAll();
})();

View File

@@ -54,6 +54,19 @@
border-color: #1c2734;
}
.fx-button--ghost {
background: #fff4fb;
border-color: #f5b7d7;
color: #ff8a00;
}
.fx-button--accent {
background: linear-gradient(90deg, #ff006a 0%, #ff9e00 100%);
color: #111827;
border-color: transparent;
font-weight: 700;
}
.fx-button[disabled] {
opacity: 0.6;
cursor: wait;
@@ -139,6 +152,122 @@
text-align: right;
}
.fx-action-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.fx-action-row form {
margin: 0;
}
.fx-mini-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin: 1rem 0 1.25rem;
}
.fx-mini-card {
display: grid;
gap: 0.2rem;
}
.fx-mini-label,
.fx-field-label {
font-size: 0.9rem;
color: #6a7383;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.fx-currency-selection-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.fx-token-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.fx-token-list--inline {
flex: 1 1 auto;
}
.fx-currency-search {
flex: 0 1 360px;
min-width: 260px;
}
.fx-input,
.fx-select {
width: 100%;
border: 1px solid #d0d7e2;
border-radius: 18px;
padding: 0.8rem 1rem;
background: #fff;
color: #1c2734;
}
.fx-field {
display: grid;
gap: 0.45rem;
margin-top: 1rem;
}
.fx-token,
.fx-suggestion {
display: inline-flex;
align-items: center;
gap: 0.6rem;
border: 1px solid #d0d7e2;
border-radius: 999px;
padding: 0.7rem 1rem;
background: #fff;
color: #1c2734;
}
.fx-token {
cursor: pointer;
}
.fx-token:hover,
.fx-suggestion:hover {
border-color: rgba(255, 158, 0, 0.45);
background: rgba(255, 244, 251, 0.9);
}
.fx-token-close {
color: #ff9e00;
font-weight: 700;
text-transform: uppercase;
}
.fx-suggestion-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.9rem;
}
.fx-suggestion {
cursor: pointer;
}
.fx-suggestion strong {
color: #111827;
}
.fx-text {
color: #5b6573;
}
.fx-history-date {
display: inline-flex;
align-items: center;
@@ -161,3 +290,15 @@
justify-content: center;
padding: 0;
}
@media (max-width: 860px) {
.fx-currency-selection-row {
flex-direction: column;
}
.fx-currency-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
}

View File

@@ -6,6 +6,7 @@
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/module/fx-rates", "match_prefixes": ["/module/fx-rates"] }
{ "label": "Ueberblick", "href": "/module/fx-rates" },
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
]
}

View File

@@ -20,8 +20,6 @@
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." },
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." },
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},

View File

@@ -0,0 +1,213 @@
<?php
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-currencies.js', 'footer', true);
}
$settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service');
$notice = trim((string) ($_GET['notice'] ?? ''));
$error = trim((string) ($_GET['error'] ?? ''));
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
try {
$action = trim((string) ($_POST['fx_action'] ?? ''));
if ($action === 'save_selection') {
$payload = [
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
];
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
if (is_array($saved) && !empty($saved['display_base_currency'])) {
$params['base'] = (string) $saved['display_base_currency'];
}
redirect('/module/fx-rates/currencies?' . http_build_query($params));
}
if ($action === 'sync_catalog') {
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
]));
}
if ($action === 'refresh_rates') {
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
]));
}
} catch (\Throwable $exception) {
redirect('/module/fx-rates/currencies?' . http_build_query([
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
]));
}
}
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
$displayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
$latest = $service->latestStatus();
$recentFetches = $service->recentFetches(15);
$currencies = [];
foreach ($catalog as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$currencies[] = [
'code' => $code,
'name' => $name,
];
}
$cryptoCodes = array_fill_keys([
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
], true);
$fiatCount = 0;
$cryptoCount = 0;
foreach ($currencies as $currency) {
if (isset($cryptoCodes[$currency['code']])) {
$cryptoCount++;
} else {
$fiatCount++;
}
}
$currencyPageData = json_encode([
'currencies' => $currencies,
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
'display_base_currency' => $displayBaseCurrency,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tabs = [
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
];
?>
<?= module_shell_header('fx-rates', [
'title' => 'Waehrungskurse',
'tabs' => $tabs,
'actions' => [
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
],
]) ?>
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
<div class="fx-stack">
<div class="fx-card">
<?php if ($notice !== ''): ?>
<div class="fx-message is-success"><?= e($notice) ?></div>
<?php elseif ($error !== ''): ?>
<div class="fx-message is-error"><?= e($error) ?></div>
<?php endif; ?>
<h2>Waehrungs-Update</h2>
<p>Auswahl wird in den FX-Rates-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
<div class="fx-action-row">
<form method="post">
<input type="hidden" name="fx_action" value="save_selection">
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
<div data-fx-hidden-preferred></div>
<button type="submit" class="fx-button fx-button--ghost">Auswahl speichern</button>
</form>
<form method="post">
<input type="hidden" name="fx_action" value="refresh_rates">
<button type="submit" class="fx-button fx-button--accent">Alle Wechselkurse aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="fx_action" value="sync_catalog">
<button type="submit" class="fx-button fx-button--ghost">Waehrungskatalog sync</button>
</form>
</div>
<div class="fx-mini-grid">
<div class="fx-mini-card">
<div class="fx-mini-label">Fiat</div>
<div><?= e((string) $fiatCount) ?> Waehrungen</div>
</div>
<div class="fx-mini-card">
<div class="fx-mini-label">Krypto</div>
<div><?= e((string) $cryptoCount) ?> Waehrungen</div>
</div>
</div>
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
<div class="fx-currency-selection-row">
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
<div class="fx-currency-search">
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
</div>
</div>
<div class="fx-suggestion-list" data-fx-suggestions></div>
<div class="fx-field fx-currency-search">
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
</div>
</div>
<div class="fx-card">
<div class="fx-card-head">
<div>
<h2>Letzte 15 Kurs-Uploads</h2>
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
</div>
<div class="fx-card-meta">
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
</div>
</div>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<th>Stichtag</th>
<th>Fetch-Basis</th>
<?php foreach ($preferredCurrencies as $currency): ?>
<th><?= e((string) $currency) ?></th>
<?php endforeach; ?>
<th>Provider</th>
</tr>
</thead>
<tbody>
<?php if ($recentFetches === []): ?>
<tr><td colspan="<?= 4 + count($preferredCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
<?php else: ?>
<?php foreach ($recentFetches as $fetch): ?>
<?php
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $displayBaseCurrency, $preferredCurrencies);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
?>
<tr>
<td><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></td>
<td><?= e((string) ($fetch['rate_date'] ?? '')) ?></td>
<td><?= e((string) ($fetch['base_currency'] ?? '')) ?></td>
<?php foreach ($preferredCurrencies as $currency): ?>
<?php $value = $rates[(string) $currency] ?? null; ?>
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '' ?></td>
<?php endforeach; ?>
<td><?= e((string) ($fetch['provider'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?= module_shell_footer() ?>

View File

@@ -687,57 +687,6 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
</div>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Waehrungen</span>
<h2>Waehrungseinstellungen</h2>
<p class="muted">Die Auswahl wird erst verfuegbar, nachdem der Waehrungskatalog synchronisiert wurde.</p>
</div>
</div>
<?php if (!$fxCatalogAvailable): ?>
<div class="setup-db-message setup-db-message--hint">
Noch kein Waehrungskatalog vorhanden. Fuehre zuerst unten die Modulaktion <strong>Waehrungskatalog synchronisieren</strong> aus.
</div>
<?php else: ?>
<div class="setup-grid">
<label class="setup-field muted">
<span>Anzeige-Basiswaehrung</span>
<input type="hidden" name="display_base_currency" value="<?= e((string) ($current['display_base_currency'] ?? '')) ?>" data-fx-base-hidden>
<div class="fx-setup-picker" data-fx-base-picker data-currencies='<?= e(is_string($fxCatalogJson) ? $fxCatalogJson : '[]') ?>'>
<input
type="text"
value="<?= e((string) (($current['display_base_currency'] ?? '') !== '' && isset($fxCatalogOptions[(string) $current['display_base_currency']]) ? ((string) $current['display_base_currency'] . ' - ' . $fxCatalogOptions[(string) $current['display_base_currency']]) : '')) ?>"
placeholder="Waehrung suchen"
autocomplete="off"
data-fx-base-input
>
<div class="fx-setup-suggestions" data-fx-base-suggestions hidden></div>
</div>
<small class="muted">Suche nach Code oder Bezeichnung und waehle einen Treffer aus.</small>
</label>
<label class="setup-field muted">
<span>Bevorzugte Waehrungen</span>
<div class="fx-setup-picker" data-fx-multi-picker data-currencies='<?= e(is_string($fxCatalogJson) ? $fxCatalogJson : '[]') ?>'>
<input type="text" value="" placeholder="Waehrung suchen und hinzufuegen" autocomplete="off" data-fx-multi-input>
<div class="fx-setup-suggestions" data-fx-multi-suggestions hidden></div>
<div class="fx-setup-tags fx-setup-tags--stacked" data-fx-tags>
<?php foreach ($fxPreferred as $code): ?>
<?php if (!isset($fxCatalogOptions[$code])) { continue; } ?>
<span class="fx-setup-tag" data-code="<?= e($code) ?>">
<?= e($code) ?>
<button type="button" data-remove-code="<?= e($code) ?>" aria-label="Entfernen">x</button>
</span>
<input type="hidden" name="preferred_currencies[]" value="<?= e($code) ?>" data-code-hidden="<?= e($code) ?>">
<?php endforeach; ?>
</div>
</div>
<small class="muted">Tippen, Treffer anklicken und zur Liste hinzufuegen.</small>
</label>
</div>
<?php endif; ?>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>