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;