diff --git a/migrations/071_bank_sources_sort_order.sql b/migrations/071_bank_sources_sort_order.sql
new file mode 100644
index 0000000..52e3212
--- /dev/null
+++ b/migrations/071_bank_sources_sort_order.sql
@@ -0,0 +1,7 @@
+-- Sortierreihenfolge der Bank-Importquellen für das Kachelraster.
+-- Initial = id, damit Bestandsdaten ihre bisherige Reihenfolge behalten.
+
+ALTER TABLE bank_sources
+ ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
+
+UPDATE bank_sources SET sort_order = id WHERE sort_order = 0;
diff --git a/public/index.php b/public/index.php
index 810e2a7..3782600 100644
--- a/public/index.php
+++ b/public/index.php
@@ -68,6 +68,7 @@ $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController(
$router->get('/bank', fn($p) => (new BankController())->index($p));
$router->get('/bank/direkt', fn($p) => (new BankController())->direkt($p));
$router->post('/bank/direkt', fn($p) => (new BankController())->direktStore($p));
+$router->post('/bank/direkt/_order', fn($p) => (new BankController())->direktOrder($p));
$router->get('/bank/direkt/{id}/run', fn($p) => (new BankController())->direktRun($p));
$router->post('/bank/direkt/{id}', fn($p) => (new BankController())->direktUpdate($p));
$router->post('/bank/direkt/{id}/delete', fn($p) => (new BankController())->direktDelete($p));
diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php
index ce4b85c..8fbc195 100644
--- a/src/Controllers/BankController.php
+++ b/src/Controllers/BankController.php
@@ -550,6 +550,22 @@ class BankController extends BaseController
View::redirect('/bank/direkt', 'Verbindung gelöscht.');
}
+ /** Speichert die per Drag-and-Drop geänderte Reihenfolge der Direktverbindungs-Kacheln. */
+ public function direktOrder(array $params): void
+ {
+ header('Content-Type: application/json');
+
+ $raw = $_POST['ids'] ?? '[]';
+ $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
+ if (!is_array($ids)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Ungültige Reihenfolge.']);
+ return;
+ }
+ BankSource::updateOrder(array_map('intval', $ids));
+ echo json_encode(['ok' => true]);
+ }
+
/** Nimmt die im Browser eingegebene TAN für einen wartenden FinTS-Abruf entgegen. */
public function direktTan(array $params): void
{
diff --git a/src/Models/BankSource.php b/src/Models/BankSource.php
index 53ca1f8..675ca35 100644
--- a/src/Models/BankSource.php
+++ b/src/Models/BankSource.php
@@ -30,7 +30,7 @@ class BankSource
$sql .= ' WHERE bs.type = ?';
$params[] = $type;
}
- $sql .= ' ORDER BY bs.type, bs.bezeichnung';
+ $sql .= ' ORDER BY bs.sort_order, bs.type, bs.bezeichnung';
$rows = Database::fetchAll($sql, $params);
return array_map([self::class, 'hydrate'], $rows);
}
@@ -90,6 +90,19 @@ class BankSource
Database::execute('UPDATE bank_sources SET last_fetched_at = NOW() WHERE id = ?', [$id]);
}
+ /** Aktualisiert sort_order anhand der übergebenen ID-Reihenfolge (Position 0..N). */
+ public static function updateOrder(array $orderedIds): void
+ {
+ foreach (array_values($orderedIds) as $pos => $id) {
+ $id = (int)$id;
+ if ($id <= 0) continue;
+ Database::execute(
+ 'UPDATE bank_sources SET sort_order = ? WHERE id = ?',
+ [$pos, $id]
+ );
+ }
+ }
+
private static function hydrate(array $row): array
{
$row['name'] = $row['bezeichnung'];
diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php
index a47ef26..6dc8e7e 100644
--- a/src/Views/bank/fints.php
+++ b/src/Views/bank/fints.php
@@ -30,11 +30,29 @@ $connectionsJson = json_encode(array_map(fn($c) => [
-
+
+
-
+
+
⠿
-
+
@@ -574,9 +592,118 @@ $connectionsJson = json_encode(array_map(fn($c) => [
btnTanSubmit.addEventListener('click', submitTan);
tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitTan(); });
+ // ── Drag & Drop für Kachel-Reihenfolge ──────────────────────
+ const grid = document.getElementById('connection-grid');
+ let dragSrc = null;
+ let didDrag = false;
+ if (grid) {
+ grid.querySelectorAll('.drag-handle').forEach(handle => {
+ handle.addEventListener('dragstart', (e) => {
+ dragSrc = handle.closest('.card');
+ didDrag = false;
+ if (!dragSrc) return;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', dragSrc.dataset.id || '');
+ // Komplette Kachel als Drag-Image, am Mauszeiger an der Klick-Position
+ const rect = dragSrc.getBoundingClientRect();
+ e.dataTransfer.setDragImage(dragSrc, e.clientX - rect.left, e.clientY - rect.top);
+ // Ursprung subtil markieren, erst nach Snapshot (sonst landet das Highlight im Ghost)
+ setTimeout(() => {
+ if (dragSrc) {
+ dragSrc.style.outline = '2px dashed #6366f1';
+ dragSrc.style.outlineOffset = '2px';
+ }
+ }, 0);
+ });
+ handle.addEventListener('dragend', () => {
+ if (dragSrc) {
+ dragSrc.style.outline = '';
+ dragSrc.style.outlineOffset = '';
+ }
+ grid.querySelectorAll('.drop-before, .drop-after').forEach(c => {
+ c.classList.remove('drop-before', 'drop-after');
+ });
+ dragSrc = null;
+ setTimeout(() => { didDrag = false; }, 50);
+ });
+ });
+ function clearDropMarkers() {
+ grid.querySelectorAll('.drop-before, .drop-after').forEach(c => {
+ c.classList.remove('drop-before', 'drop-after');
+ });
+ }
+
+ function findDropTarget(clientX, clientY) {
+ const cards = [...grid.querySelectorAll('.card')].filter(c => c !== dragSrc);
+ // Erst: Karte unter dem Cursor (bevorzugt)
+ for (const card of cards) {
+ const r = card.getBoundingClientRect();
+ if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
+ return { card, side: (clientX < r.left + r.width / 2) ? 'before' : 'after' };
+ }
+ }
+ // Fallback: nächste Karte in derselben Zeile (anhand Y)
+ let best = null, bestDist = Infinity;
+ for (const card of cards) {
+ const r = card.getBoundingClientRect();
+ if (clientY >= r.top && clientY <= r.bottom) {
+ const center = r.left + r.width / 2;
+ const dist = Math.abs(clientX - center);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = { card, side: (clientX < center) ? 'before' : 'after' };
+ }
+ }
+ }
+ return best;
+ }
+
+ grid.addEventListener('dragover', (e) => {
+ if (!dragSrc) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const target = findDropTarget(e.clientX, e.clientY);
+ if (!target) { clearDropMarkers(); return; }
+ const cls = target.side === 'before' ? 'drop-before' : 'drop-after';
+ // Nur neu setzen wenn nötig (vermeidet Flackern)
+ if (target.card.classList.contains(cls)) return;
+ clearDropMarkers();
+ target.card.classList.add(cls);
+ });
+ grid.addEventListener('dragleave', (e) => {
+ // Nur wenn der Grid komplett verlassen wird
+ if (e.target === grid && !grid.contains(e.relatedTarget)) {
+ clearDropMarkers();
+ }
+ });
+ grid.addEventListener('drop', (e) => {
+ if (!dragSrc) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const marker = grid.querySelector('.drop-before, .drop-after');
+ if (marker) {
+ const before = marker.classList.contains('drop-before');
+ marker.parentNode.insertBefore(dragSrc, before ? marker : marker.nextSibling);
+ didDrag = true;
+ persistOrder();
+ }
+ clearDropMarkers();
+ });
+ }
+ async function persistOrder() {
+ const ids = Array.from(grid.querySelectorAll('.card[data-id]')).map(c => c.dataset.id);
+ const form = new FormData();
+ form.append('ids', JSON.stringify(ids));
+ try {
+ await fetch('/bank/direkt/_order', { method: 'POST', body: form });
+ } catch (e) { /* still local */ }
+ }
+
document.querySelectorAll('.btn-run-connection').forEach(card => {
card.addEventListener('click', (e) => {
+ if (didDrag) { didDrag = false; return; }
if (e.target.closest('.btn-edit-connection')) return;
+ if (e.target.closest('.drag-handle')) return;
const id = card.dataset.id;
currentSourceId = id;