Files
nexus/partials/landingpages/modules/setup.php
Lars Gebhardt-Kusche 9e687455fa
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
sdadas
2026-04-29 01:57:49 +02:00

1168 lines
46 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$moduleName = (string)($_GET['module'] ?? '');
$module = modules()->get($moduleName);
$error = null;
$notice = null;
$testGroup = null;
$dbTestMessages = [];
require_admin();
if (!$module) {
http_response_code(404);
echo '<div class="card">Modul nicht gefunden.</div>';
return;
}
$fields = (array)($module['setup']['fields'] ?? []);
$hasGlobalDebugField = false;
foreach ($fields as $field) {
if ((string)($field['name'] ?? '') === 'debug_enabled') {
$hasGlobalDebugField = true;
break;
}
}
if (!$hasGlobalDebugField) {
$fields[] = [
'name' => 'debug_enabled',
'label' => 'Modul-Debug aktivieren',
'type' => 'checkbox',
'help' => 'Wenn aktiv, darf das Modul Debug-Daten sammeln und den Debug-Bereich anzeigen.',
];
}
$fieldTypes = [];
$fieldMeta = [];
foreach ($fields as $field) {
$fname = (string)($field['name'] ?? '');
if ($fname === '') {
continue;
}
$fieldTypes[$fname] = (string)($field['type'] ?? 'text');
$fieldMeta[$fname] = $field;
}
$isFxRatesSetup = $moduleName === 'fx-rates';
$current = modules()->settings($moduleName);
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
? (array) module_fn($moduleName, 'setup_actions')
: [];
$defaults = $module['db_defaults'] ?? [];
if (empty($current['db']) && is_array($defaults)) {
$current['db'] = $defaults;
}
$metadataDefaults = $module['metadata_db_defaults'] ?? [];
if (empty($current['metadata_db']) && is_array($metadataDefaults)) {
$current['metadata_db'] = $metadataDefaults;
}
$dbDefaultsByGroup = [
'db' => is_array($defaults) ? $defaults : [],
'metadata_db' => is_array($metadataDefaults) ? $metadataDefaults : [],
];
$setNested = function (array &$target, string $path, mixed $value): void {
$parts = explode('.', $path);
$last = array_pop($parts);
$node = &$target;
foreach ($parts as $part) {
if (!isset($node[$part]) || !is_array($node[$part])) {
$node[$part] = [];
}
$node = &$node[$part];
}
if ($last !== null && $last !== '') {
$node[$last] = $value;
}
};
$getNested = function (array $source, string $path): mixed {
$node = $source;
foreach (explode('.', $path) as $part) {
if (!is_array($node) || !array_key_exists($part, $node)) {
return null;
}
$node = $node[$part];
}
return $node;
};
$dbGroups = [];
$fieldsByDbGroup = [];
$generalFields = [];
foreach ($fields as $field) {
$name = (string)($field['name'] ?? '');
if (!str_contains($name, '.')) {
continue;
}
[$group, $key] = explode('.', $name, 2);
if ($key !== 'driver') {
continue;
}
$label = (string)($field['label'] ?? $group);
$label = trim(preg_replace('/\s+DB\s+Driver$/i', ' DB', $label) ?? $label);
$label = $label !== '' ? $label : $group;
$dbGroups[$group] = $label;
}
foreach ($fields as $field) {
$name = (string)($field['name'] ?? '');
if (str_contains($name, '.')) {
[$group] = explode('.', $name, 2);
if (array_key_exists($group, $dbGroups)) {
$fieldsByDbGroup[$group][] = $field;
continue;
}
}
$generalFields[] = $field;
}
$driverOptions = [
'pgsql' => 'PostgreSQL',
'mysql' => 'MySQL / MariaDB',
'sqlite' => 'SQLite',
];
$describeDbConfig = static function (array $dbConfig): string {
$driver = (string)($dbConfig['driver'] ?? '');
$host = (string)($dbConfig['host'] ?? '');
$port = (string)($dbConfig['port'] ?? '');
return 'Aktive Einstellung: Treiber ' . ($driver !== '' ? $driver : 'nicht gesetzt')
. ', Host ' . ($host !== '' ? $host : 'nicht gesetzt')
. ', Port ' . ($port !== '' ? $port : 'nicht gesetzt') . '.';
};
$describeDbDefaults = static function (array $dbDefaults): string {
$driver = (string)($dbDefaults['driver'] ?? '');
$host = (string)($dbDefaults['host'] ?? '');
$port = (string)($dbDefaults['port'] ?? '');
if ($driver === '' && $host === '' && $port === '') {
return '';
}
return 'Standard: Treiber ' . ($driver !== '' ? $driver : 'nicht gesetzt')
. ', Host ' . ($host !== '' ? $host : 'nicht gesetzt')
. ', Port ' . ($port !== '' ? $port : 'nicht gesetzt') . '.';
};
$dbConfigWarning = static function (array $dbConfig): ?string {
$driver = (string)($dbConfig['driver'] ?? '');
$port = (string)($dbConfig['port'] ?? '');
if ($driver === 'pgsql' && $port === '3306') {
return 'Port 3306 ist typisch fuer MySQL/MariaDB, aber als Treiber ist PostgreSQL ausgewaehlt. Bitte den Treiber auf MySQL / MariaDB stellen.';
}
if ($driver === 'mysql' && $port === '5432') {
return 'Port 5432 ist typisch fuer PostgreSQL, aber als Treiber ist MySQL / MariaDB ausgewaehlt. Bitte Port oder Treiber pruefen.';
}
return null;
};
$dbConfigHint = static function (string $group, array $dbConfig, array $dbDefaults): ?string {
if ($group !== 'metadata_db') {
return null;
}
$driver = (string)($dbConfig['driver'] ?? '');
$host = (string)($dbConfig['host'] ?? '');
$port = (string)($dbConfig['port'] ?? '');
$defaultDriver = (string)($dbDefaults['driver'] ?? '');
$defaultHost = (string)($dbDefaults['host'] ?? '');
$defaultPort = (string)($dbDefaults['port'] ?? '');
if ($driver !== $defaultDriver || $host !== $defaultHost || $port !== $defaultPort) {
return 'Diese Verbindung ist fuer Nexus-eigene DHCP-Zusatzinfos gedacht, nicht fuer die Nexus-App/Base-DB. Erwartet wird normalerweise: '
. ($defaultDriver !== '' ? $defaultDriver : 'Treiber offen')
. ' auf '
. ($defaultHost !== '' ? $defaultHost : 'Host offen')
. ':'
. ($defaultPort !== '' ? $defaultPort : 'Port offen')
. '.';
}
return null;
};
$fetchJsonWithApiKey = static function (string $url, string $apiKey, int $timeout = 10): array {
$headers = [
'Accept: application/json',
'x-api-key: ' . $apiKey,
];
$responseBody = null;
$httpCode = 0;
$curlError = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch !== false) {
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
CURLOPT_HTTPHEADER => $headers,
]);
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
}
}
if (!is_string($responseBody) || $responseBody === '') {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'header' => implode("\r\n", $headers) . "\r\n",
],
]);
$responseBody = @file_get_contents($url, false, $context);
}
if (!is_string($responseBody) || $responseBody === '') {
return [
'ok' => false,
'message' => 'Abruf fehlgeschlagen.'
. ($curlError !== '' ? ' ' . $curlError : '')
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
];
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return [
'ok' => false,
'message' => 'Antwort ist kein gueltiges JSON.',
];
}
foreach (['error', 'message', 'detail'] as $errorKey) {
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
return [
'ok' => false,
'message' => trim((string) $decoded[$errorKey]),
];
}
}
return [
'ok' => true,
'data' => $decoded,
];
};
$normalizeDriver = static function (mixed $value): mixed {
if (!is_string($value)) {
return $value;
}
$normalized = strtolower(trim($value));
return match ($normalized) {
'postgres', 'postgresql' => 'pgsql',
'mariadb', 'mysql/mariadb', 'mysql / mariadb' => 'mysql',
default => $normalized,
};
};
$formatRunTimestamp = static function (?string $value): string {
$value = trim((string) $value);
if ($value === '') {
return '-';
}
$ts = strtotime($value);
if ($ts === false) {
return $value;
}
return date('Y-m-d H:i:s', $ts);
};
$renderField = function (array $field) use (&$current, $getNested, $driverOptions): void {
$name = (string)($field['name'] ?? '');
if ($name === '') {
return;
}
$label = (string)($field['label'] ?? $name);
$type = (string)($field['type'] ?? 'text');
$required = !empty($field['required']);
$help = (string)($field['help'] ?? $field['description'] ?? '');
$postKey = str_replace('.', '_', $name);
$value = '';
if ($name === 'kea_auto_init') {
$value = !empty($current[$name]) ? '1' : '0';
} elseif (str_contains($name, '.')) {
$value = (string)($getNested($current, $name) ?? '');
} else {
$value = (string)($current[$name] ?? '');
}
?>
<label class="setup-field muted">
<span><?= e($label) ?></span>
<?php if (str_ends_with($name, '.driver')): ?>
<select name="<?= e($postKey) ?>" <?= $required ? 'required' : '' ?>>
<option value="">Bitte waehlen</option>
<?php foreach ($driverOptions as $driver => $driverLabel): ?>
<option value="<?= e($driver) ?>" <?= $value === $driver ? 'selected' : '' ?>><?= e($driverLabel) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($type === 'textarea'): ?>
<textarea name="<?= e($postKey) ?>" rows="3" <?= $required ? 'required' : '' ?>><?= e($value) ?></textarea>
<?php elseif ($type === 'checkbox'): ?>
<input type="checkbox" name="<?= e($postKey) ?>" value="1" <?= $value === '1' ? 'checked' : '' ?>>
<?php else: ?>
<input type="<?= e($type) ?>" name="<?= e($postKey) ?>" value="<?= e($value) ?>" <?= $required ? 'required' : '' ?>>
<?php endif; ?>
<?php if ($help !== ''): ?>
<small class="muted"><?= e($help) ?></small>
<?php endif; ?>
</label>
<?php
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$payload = [];
foreach ($fields as $field) {
$name = (string)($field['name'] ?? '');
if ($name === '') {
continue;
}
$type = (string)($field['type'] ?? 'text');
$postKey = str_replace('.', '_', $name);
$value = $_POST[$postKey] ?? null;
if ($type === 'checkbox') {
$value = isset($_POST[$postKey]) ? '1' : '0';
}
if (is_array($value) && $type === 'multiselect') {
$value = array_values(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$value
), static fn (string $item): bool => $item !== ''));
} elseif (is_array($value)) {
continue;
}
$value = is_string($value) ? trim($value) : $value;
if (str_ends_with($name, '.driver')) {
$value = $normalizeDriver($value);
}
if ($name === 'kea_auto_init') {
$payload[$name] = $value === '1';
continue;
}
if (str_contains($name, '.')) {
$setNested($payload, $name, $value);
continue;
}
$payload[$name] = $value;
}
$current = array_replace_recursive($current, $payload);
$postedTestGroup = (string)($_POST['test_db'] ?? '');
$postedResetGroup = (string)($_POST['reset_db'] ?? '');
$postedSetupAction = trim((string)($_POST['module_setup_action'] ?? ''));
if ($postedSetupAction !== '') {
if (!modules()->hasFunction($moduleName, 'run_setup_action')) {
$error = 'Diese Setup-Aktion wird vom Modul nicht unterstuetzt.';
} else {
try {
$actionResult = module_fn($moduleName, 'run_setup_action', $postedSetupAction);
$current = modules()->settings($moduleName);
$notice = 'Setup-Aktion ausgefuehrt.';
if (is_array($actionResult)) {
if (isset($actionResult['message']) && is_string($actionResult['message']) && trim($actionResult['message']) !== '') {
$notice = trim((string) $actionResult['message']);
} elseif (isset($actionResult['synced_count'])) {
$notice = 'Waehrungskatalog synchronisiert. ' . (int) $actionResult['synced_count'] . ' Waehrungen verarbeitet.';
}
}
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
} elseif ($postedResetGroup !== '') {
$testGroup = $postedResetGroup;
if (!array_key_exists($postedResetGroup, $dbGroups)) {
$error = 'Unbekannte Datenbank-Konfiguration.';
} elseif (($dbDefaultsByGroup[$postedResetGroup] ?? []) === []) {
$dbTestMessages[$postedResetGroup] = [
'type' => 'error',
'text' => 'Fuer diese Datenbank sind keine Standardwerte hinterlegt.',
];
} else {
$current[$postedResetGroup] = $dbDefaultsByGroup[$postedResetGroup];
$dbTestMessages[$postedResetGroup] = [
'type' => 'success',
'text' => 'Standardwerte geladen. Bitte pruefen und speichern.',
];
}
} elseif ($postedTestGroup !== '') {
$testGroup = $postedTestGroup;
if (!array_key_exists($postedTestGroup, $dbGroups)) {
$error = 'Unbekannte Datenbank-Konfiguration.';
} else {
$dbConfig = $getNested($current, $postedTestGroup);
if (!is_array($dbConfig)) {
$dbTestMessages[$postedTestGroup] = [
'type' => 'error',
'text' => 'Datenbank-Konfiguration ist unvollstaendig.',
];
} else {
$warning = $dbConfigWarning($dbConfig);
if ($warning !== null) {
$dbTestMessages[$postedTestGroup] = [
'type' => 'error',
'text' => $warning . ' ' . $describeDbConfig($dbConfig),
];
} else {
try {
$dbConfig['options'] = array_replace([
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
], (array)($dbConfig['options'] ?? []));
$testPdo = \App\Database::createFromArray($dbConfig);
$testPdo->query('SELECT 1')->fetchColumn();
$dbTestMessages[$postedTestGroup] = [
'type' => 'success',
'text' => 'Verbindung erfolgreich. ' . $describeDbConfig($dbConfig),
];
} catch (\Throwable $e) {
$dbTestMessages[$postedTestGroup] = [
'type' => 'error',
'text' => 'Verbindung fehlgeschlagen. ' . $describeDbConfig($dbConfig) . ' ' . $e->getMessage(),
];
}
}
}
}
} else {
modules()->saveSettings($moduleName, $current);
if ($isFxRatesSetup && modules()->hasFunction($moduleName, 'save_runtime_settings')) {
module_fn($moduleName, 'save_runtime_settings', $payload);
$current = modules()->settings($moduleName);
}
if (empty($payload['debug_enabled'])) {
module_debug_clear($moduleName);
}
$notice = 'Setup gespeichert.';
$module = modules()->get($moduleName) ?: $module;
}
}
$moduleStatusPanel = null;
$activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
? $testGroup
: (array_key_first($dbGroups) ?? '');
?>
<div class="setup-shell">
<div class="pill">Setup</div>
<h1 class="setup-title"><?= e($module['title']) ?> Einrichtung</h1>
<p class="muted">Trage die benötigten Informationen für das Modul ein.</p>
<?php if ($error): ?>
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="setup-notice">
<?= e($notice) ?>
</div>
<?php endif; ?>
<form method="post" class="setup-form">
<?php if ($isFxRatesSetup): ?>
<?php
$fxCatalog = is_array($current['currency_catalog'] ?? null) ? $current['currency_catalog'] : [];
$fxCatalogOptions = [];
foreach ($fxCatalog as $currency) {
if (!is_array($currency)) {
continue;
}
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
$name = trim((string) ($currency['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$fxCatalogOptions[$code] = $name;
}
ksort($fxCatalogOptions);
$fxCatalogAvailable = $fxCatalogOptions !== [];
$fxPreferred = is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [];
$fxUseSeparateDb = !empty($current['use_separate_db']);
$fxCatalogJson = json_encode(array_map(
static fn (string $name, string $code): array => ['code' => $code, 'name' => $name],
array_values($fxCatalogOptions),
array_keys($fxCatalogOptions)
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">API</span>
<h2>Provider und Abruf</h2>
</div>
</div>
<div class="setup-grid">
<label class="setup-field muted">
<span>FX Provider</span>
<input type="text" name="provider" value="<?= e((string) ($current['provider'] ?? 'currencyapi')) ?>">
<small class="muted">Unterstuetzt legacy currencyapi.net und currencyapi.com v3.</small>
</label>
<label class="setup-field muted">
<span>FX API URL</span>
<input type="text" name="api_url" value="<?= e((string) ($current['api_url'] ?? 'https://currencyapi.net')) ?>">
<small class="muted">Nur die Basis-URL eintragen, z.B. https://currencyapi.net oder https://api.currencyapi.com.</small>
</label>
<label class="setup-field muted">
<span>FX API Key</span>
<input type="password" name="api_key" value="<?= e((string) ($current['api_key'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Timeout (Sek.)</span>
<input type="number" name="timeout_sec" value="<?= e((string) ($current['timeout_sec'] ?? 10)) ?>">
</label>
<label class="setup-field muted">
<span>Datei-Cache TTL (Sek.)</span>
<input type="number" name="cache_ttl_sec" value="<?= e((string) ($current['cache_ttl_sec'] ?? 21600)) ?>">
</label>
<label class="setup-field muted">
<span>Standard-Basiswaehrung</span>
<input type="text" name="default_base_currency" value="<?= e((string) ($current['default_base_currency'] ?? 'EUR')) ?>">
</label>
</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>
<span class="pill">Scheduler</span>
<h2>Taeglicher Abruf</h2>
</div>
</div>
<div class="setup-grid">
<label class="setup-field muted">
<span>Taeglichen Abruf aktivieren</span>
<input type="checkbox" name="daily_refresh_enabled" value="1" <?= !empty($current['daily_refresh_enabled']) ? 'checked' : '' ?>>
</label>
<label class="setup-field muted">
<span>Taegliche Abrufstunde</span>
<input type="number" name="daily_refresh_hour" min="0" max="23" value="<?= e((string) ($current['daily_refresh_hour'] ?? 18)) ?>">
</label>
<label class="setup-field muted">
<span>Scheduler-Zeitzone</span>
<input type="text" name="schedule_timezone" value="<?= e((string) ($current['schedule_timezone'] ?? 'Europe/Berlin')) ?>">
</label>
</div>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">DB</span>
<h2>Datenbank</h2>
</div>
</div>
<div class="setup-grid">
<label class="setup-field muted">
<span>Eigene Modul-DB nutzen</span>
<input type="checkbox" name="use_separate_db" value="1" <?= $fxUseSeparateDb ? 'checked' : '' ?> data-fx-db-toggle>
<small class="muted">Wenn aktiv, werden die folgenden DB-Daten verwendet. Sonst wird die Nexus-Base-DB genutzt.</small>
</label>
</div>
<div data-fx-db-section <?= $fxUseSeparateDb ? '' : 'hidden' ?>>
<div class="setup-grid" style="margin-top:16px;">
<label class="setup-field muted">
<span>DB Driver</span>
<select name="db_driver">
<option value="">Bitte waehlen</option>
<?php foreach ($driverOptions as $driver => $driverLabel): ?>
<option value="<?= e($driver) ?>" <?= (string) ($current['db']['driver'] ?? '') === $driver ? 'selected' : '' ?>><?= e($driverLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>DB Host</span>
<input type="text" name="db_host" value="<?= e((string) ($current['db']['host'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Port</span>
<input type="number" name="db_port" value="<?= e((string) ($current['db']['port'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Name</span>
<input type="text" name="db_dbname" value="<?= e((string) ($current['db']['dbname'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Schema</span>
<input type="text" name="db_schema" value="<?= e((string) ($current['db']['schema'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB User</span>
<input type="text" name="db_user" value="<?= e((string) ($current['db']['user'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Passwort</span>
<input type="password" name="db_password" value="<?= e((string) ($current['db']['password'] ?? '')) ?>">
</label>
</div>
</div>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Aktionen</span>
<h2>Modulaktionen</h2>
</div>
</div>
<div class="setup-grid">
<div class="setup-field muted">
<span>Waehrungssynch</span>
<small class="muted">Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.</small>
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
<button class="nav-link" type="submit" name="module_setup_action" value="sync_currency_catalog" formnovalidate>
Waehrungskatalog synchronisieren
</button>
</div>
<?php if (trim((string) ($current['currency_catalog_synced_at'] ?? '')) !== ''): ?>
<small class="muted">Letzter Sync: <?= e((string) $current['currency_catalog_synced_at']) ?></small>
<?php endif; ?>
</div>
<label class="setup-field muted">
<span>Modul-Debug aktivieren</span>
<input type="checkbox" name="debug_enabled" value="1" <?= !empty($current['debug_enabled']) ? 'checked' : '' ?>>
<small class="muted">Wenn aktiv, darf das Modul Debug-Daten sammeln und den Debug-Bereich anzeigen.</small>
</label>
</div>
</section>
<?php if ($intervalTaskStatuses !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Automationen</span>
<h2>Intervall-Aufgaben</h2>
<p class="muted">Diese Aufgaben werden beim ersten gueltigen Modulaufruf nach Ablauf des Intervalls automatisch ausgefuehrt.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($intervalTaskStatuses as $task): ?>
<?php $state = is_array($task['state'] ?? null) ? $task['state'] : []; ?>
<div class="setup-field muted">
<span><?= e((string) ($task['label'] ?? $task['name'] ?? 'Intervall-Aufgabe')) ?></span>
<input type="text" readonly value="<?= e(!empty($task['enabled']) ? 'Aktiv' : 'Deaktiviert') ?>">
<small class="muted">Intervall: <?= e(number_format((float) ($task['interval_hours'] ?? 0), 2, ',', '')) ?> Stunden</small>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($state['last_started_at'] ?? ''))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($state['last_success_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<div class="setup-actions">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
<a class="nav-link" href="/modules">Zurück</a>
</div>
<script>
(() => {
const toggle = document.querySelector('[data-fx-db-toggle]');
const section = document.querySelector('[data-fx-db-section]');
if (!toggle || !section) return;
const sync = () => {
section.hidden = !toggle.checked;
};
toggle.addEventListener('change', sync);
sync();
})();
(() => {
const baseRoot = document.querySelector('[data-fx-base-picker]');
const baseHidden = document.querySelector('[data-fx-base-hidden]');
const baseInput = document.querySelector('[data-fx-base-input]');
const baseSuggestions = document.querySelector('[data-fx-base-suggestions]');
const multiRoot = document.querySelector('[data-fx-multi-picker]');
const multiInput = document.querySelector('[data-fx-multi-input]');
const multiSuggestions = document.querySelector('[data-fx-multi-suggestions]');
const tagsRoot = document.querySelector('[data-fx-tags]');
const parseCurrencies = (root) => {
if (!root) return [];
try {
const parsed = JSON.parse(root.dataset.currencies || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const currencies = parseCurrencies(baseRoot || multiRoot);
const formatLabel = (item) => `${item.code} - ${item.name}`;
const searchCurrencies = (query, excluded = []) => {
const needle = String(query || '').trim().toLowerCase();
const excludedSet = new Set(excluded);
const filtered = currencies.filter((item) => {
if (!item || !item.code || excludedSet.has(item.code)) {
return false;
}
if (needle === '') {
return true;
}
return item.code.toLowerCase().includes(needle) || String(item.name || '').toLowerCase().includes(needle);
});
return filtered.slice(0, 12);
};
const closeSuggestions = (node) => {
if (!node) return;
node.hidden = true;
node.innerHTML = '';
};
const renderSuggestions = (node, items, onSelect) => {
if (!node || items.length === 0) {
closeSuggestions(node);
return;
}
node.innerHTML = '';
items.forEach((item) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'fx-setup-suggestion';
button.textContent = formatLabel(item);
button.addEventListener('click', () => onSelect(item));
node.appendChild(button);
});
node.hidden = false;
};
if (baseRoot && baseHidden && baseInput && baseSuggestions) {
const applyBase = (item) => {
baseHidden.value = item.code;
baseInput.value = formatLabel(item);
closeSuggestions(baseSuggestions);
};
baseInput.addEventListener('input', () => {
baseHidden.value = '';
renderSuggestions(baseSuggestions, searchCurrencies(baseInput.value), applyBase);
});
baseInput.addEventListener('focus', () => {
renderSuggestions(baseSuggestions, searchCurrencies(baseInput.value), applyBase);
});
baseInput.form?.addEventListener('submit', () => {
if (baseHidden.value !== '') {
return;
}
const first = searchCurrencies(baseInput.value)[0] || null;
if (first) {
applyBase(first);
}
});
}
if (multiRoot && multiInput && multiSuggestions && tagsRoot) {
const selectedCodes = () => Array.from(tagsRoot.querySelectorAll('[data-code]')).map((node) => node.getAttribute('data-code') || '');
const addTag = (item) => {
if (!item || selectedCodes().includes(item.code)) {
return;
}
const tag = document.createElement('span');
tag.className = 'fx-setup-tag';
tag.setAttribute('data-code', item.code);
tag.append(document.createTextNode(item.code));
const remove = document.createElement('button');
remove.type = 'button';
remove.setAttribute('data-remove-code', item.code);
remove.setAttribute('aria-label', 'Entfernen');
remove.textContent = 'x';
remove.addEventListener('click', () => {
tag.remove();
hidden.remove();
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
tag.appendChild(remove);
tagsRoot.appendChild(tag);
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'preferred_currencies[]';
hidden.value = item.code;
hidden.setAttribute('data-code-hidden', item.code);
multiRoot.appendChild(hidden);
multiInput.value = '';
renderSuggestions(multiSuggestions, searchCurrencies('', selectedCodes()), addTag);
};
tagsRoot.querySelectorAll('[data-remove-code]').forEach((button) => {
button.addEventListener('click', () => {
const code = button.getAttribute('data-remove-code') || '';
tagsRoot.querySelector(`[data-code="${code}"]`)?.remove();
multiRoot.querySelector(`[data-code-hidden="${code}"]`)?.remove();
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
});
multiInput.addEventListener('input', () => {
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
multiInput.addEventListener('focus', () => {
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
}
document.addEventListener('click', (event) => {
if (baseRoot && !baseRoot.contains(event.target)) {
closeSuggestions(baseSuggestions);
}
if (multiRoot && !multiRoot.contains(event.target)) {
closeSuggestions(multiSuggestions);
}
});
})();
</script>
<style>
.fx-setup-picker {
position: relative;
display: grid;
gap: 10px;
}
.fx-setup-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fx-setup-tags--stacked {
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-strong);
}
.fx-setup-tag {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
font-size: 0.86rem;
font-weight: 700;
}
.fx-setup-tag button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.fx-setup-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
display: grid;
gap: 6px;
margin-top: 4px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-strong);
box-shadow: 0 16px 35px rgba(1, 22, 32, 0.14);
max-height: 280px;
overflow: auto;
}
.fx-setup-suggestion {
appearance: none;
text-align: left;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text);
padding: 8px 10px;
cursor: pointer;
font: inherit;
}
.fx-setup-suggestion:hover,
.fx-setup-suggestion:focus-visible {
border-color: color-mix(in srgb, var(--brand-accent) 36%, transparent);
background: color-mix(in srgb, var(--brand-accent) 8%, var(--surface-strong));
outline: none;
}
</style>
<?php else: ?>
<?php if ($generalFields !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Allgemein</span>
<h2>Moduleinstellungen</h2>
</div>
</div>
<div class="setup-grid">
<?php foreach ($generalFields as $field): ?>
<?php $renderField($field); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ($intervalTaskStatuses !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Automationen</span>
<h2>Intervall-Aufgaben</h2>
<p class="muted">Diese Aufgaben werden beim ersten gueltigen Modulaufruf nach Ablauf des Intervalls automatisch ausgefuehrt.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($intervalTaskStatuses as $task): ?>
<?php $state = is_array($task['state'] ?? null) ? $task['state'] : []; ?>
<div class="setup-field muted">
<span><?= e((string) ($task['label'] ?? $task['name'] ?? 'Intervall-Aufgabe')) ?></span>
<input type="text" readonly value="<?= e(!empty($task['enabled']) ? 'Aktiv' : 'Deaktiviert') ?>">
<small class="muted">Intervall: <?= e(number_format((float) ($task['interval_hours'] ?? 0), 2, ',', '')) ?> Stunden</small>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($state['last_started_at'] ?? ''))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($state['last_success_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
<small class="muted">Status: <?= e((string) (($state['last_status'] ?? '') !== '' ? $state['last_status'] : '-')) ?></small>
<?php if (trim((string) ($state['last_message'] ?? '')) !== ''): ?>
<small class="muted">Meldung: <?= e((string) $state['last_message']) ?></small>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ($setupActions !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Aktionen</span>
<h2>Modulaktionen</h2>
<p class="muted">Seltene Wartungsaktionen koennen direkt hier aus dem Setup ausgefuehrt werden.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($setupActions as $action): ?>
<?php
$actionName = trim((string)($action['name'] ?? ''));
$actionLabel = trim((string)($action['label'] ?? $actionName));
$actionHelp = trim((string)($action['help'] ?? ''));
if ($actionName === '' || $actionLabel === '') {
continue;
}
?>
<div class="setup-field muted">
<span><?= e($actionLabel) ?></span>
<?php if ($actionHelp !== ''): ?>
<small class="muted"><?= e($actionHelp) ?></small>
<?php endif; ?>
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
<button class="nav-link" type="submit" name="module_setup_action" value="<?= e($actionName) ?>" formnovalidate>
<?= e($actionLabel) ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if (is_array($moduleStatusPanel)): ?>
<section class="setup-panel setup-panel--flat">
<div class="setup-panel__head">
<div>
<span class="pill">Status</span>
<h2><?= e((string) $moduleStatusPanel['title']) ?></h2>
<p class="muted">Der Status wird beim Aufruf der Setup-Seite automatisch geprueft.</p>
</div>
</div>
<div class="setup-db-message setup-db-message--<?= e((string) $moduleStatusPanel['type']) ?>">
<?= e((string) $moduleStatusPanel['text']) ?>
</div>
<?php if (!empty($moduleStatusPanel['stats']) && is_array($moduleStatusPanel['stats'])): ?>
<div class="setup-grid" style="margin-top:16px;">
<?php foreach ($moduleStatusPanel['stats'] as $stat): ?>
<label class="setup-field muted">
<span><?= e((string) ($stat['label'] ?? '')) ?></span>
<input type="text" value="<?= e((string) ($stat['value'] ?? '')) ?>" readonly>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php if ($dbGroups !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Datenbanken</span>
<h2>Verbindungen</h2>
<p class="muted">Jede Verbindung kann getrennt konfiguriert und getestet werden.</p>
</div>
</div>
<div class="setup-tabs" aria-label="Datenbankbereiche">
<?php foreach ($dbGroups as $group => $label): ?>
<button
class="nav-link setup-tab<?= $group === $activeDbGroup ? ' is-active' : '' ?>"
type="button"
data-setup-tab-target="setup-db-<?= e($group) ?>"
aria-controls="setup-db-<?= e($group) ?>"
aria-selected="<?= $group === $activeDbGroup ? 'true' : 'false' ?>"
>
<?= e($label) ?>
</button>
<?php endforeach; ?>
</div>
<div class="setup-db-panels">
<?php foreach ($dbGroups as $group => $label): ?>
<?php
$panelConfig = $getNested($current, $group);
$panelConfig = is_array($panelConfig) ? $panelConfig : [];
$panelDefaults = $dbDefaultsByGroup[$group] ?? [];
$panelDefaults = is_array($panelDefaults) ? $panelDefaults : [];
$panelHint = $dbConfigHint($group, $panelConfig, $panelDefaults);
$defaultDescription = $describeDbDefaults($panelDefaults);
?>
<section class="setup-db-panel" id="setup-db-<?= e($group) ?>" <?= $group === $activeDbGroup ? '' : 'hidden' ?>>
<div class="setup-panel__head">
<div>
<span class="pill"><?= e($label) ?></span>
<h3><?= e($label) ?> konfigurieren</h3>
<?php if ($defaultDescription !== ''): ?>
<p class="muted"><?= e($defaultDescription) ?></p>
<?php endif; ?>
</div>
<div class="setup-db-actions">
<?php if ($panelDefaults !== []): ?>
<button class="nav-link" type="submit" name="reset_db" value="<?= e($group) ?>" formnovalidate>
Standardwerte laden
</button>
<?php endif; ?>
<button class="nav-link" type="submit" name="test_db" value="<?= e($group) ?>" formnovalidate>
Verbindung testen
</button>
</div>
</div>
<?php if (isset($dbTestMessages[$group])): ?>
<div class="setup-db-message setup-db-message--<?= e($dbTestMessages[$group]['type']) ?>">
<?= e($dbTestMessages[$group]['text']) ?>
</div>
<?php elseif ($panelHint !== null): ?>
<div class="setup-db-message setup-db-message--hint">
<?= e($panelHint) ?>
</div>
<?php endif; ?>
<div class="setup-grid">
<?php foreach (($fieldsByDbGroup[$group] ?? []) as $field): ?>
<?php $renderField($field); ?>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<div class="setup-actions">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
<a class="nav-link" href="/modules">Zurück</a>
</div>
<?php endif; ?>
</form>
</div>