Feature: Drag-and-Drop Reihenfolge der Direktverbindungs-Kacheln

- Migration 071 ergänzt bank_sources.sort_order
- Drag-Handle (⠿) oben links jeder Kachel
- Gezogene Kachel hängt am Mauszeiger, Ursprung mit lila Rahmen
- Blauer Strich im Gap markiert die Einfügeposition
- Reihenfolge wird per AJAX nach /bank/direkt/_order persistiert
This commit is contained in:
fds2610-bot 2026-05-22 09:24:56 +02:00
parent 995b52fbd3
commit b42758eb6e
5 changed files with 168 additions and 4 deletions

View file

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

View file

@ -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', fn($p) => (new BankController())->index($p));
$router->get('/bank/direkt', fn($p) => (new BankController())->direkt($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', 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->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}', fn($p) => (new BankController())->direktUpdate($p));
$router->post('/bank/direkt/{id}/delete', fn($p) => (new BankController())->direktDelete($p)); $router->post('/bank/direkt/{id}/delete', fn($p) => (new BankController())->direktDelete($p));

View file

@ -550,6 +550,22 @@ class BankController extends BaseController
View::redirect('/bank/direkt', 'Verbindung gelöscht.'); 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. */ /** Nimmt die im Browser eingegebene TAN für einen wartenden FinTS-Abruf entgegen. */
public function direktTan(array $params): void public function direktTan(array $params): void
{ {

View file

@ -30,7 +30,7 @@ class BankSource
$sql .= ' WHERE bs.type = ?'; $sql .= ' WHERE bs.type = ?';
$params[] = $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); $rows = Database::fetchAll($sql, $params);
return array_map([self::class, 'hydrate'], $rows); 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]); 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 private static function hydrate(array $row): array
{ {
$row['name'] = $row['bezeichnung']; $row['name'] = $row['bezeichnung'];

View file

@ -30,11 +30,29 @@ $connectionsJson = json_encode(array_map(fn($c) => [
</div> </div>
</div> </div>
<?php else: ?> <?php else: ?>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem"> <style>
#connection-grid .card.drop-before::before,
#connection-grid .card.drop-after::after {
content: '';
position: absolute;
top: .25rem;
bottom: .25rem;
width: 4px;
background: #6366f1;
border-radius: 2px;
pointer-events: none;
}
#connection-grid .card.drop-before::before { left: -10px; }
#connection-grid .card.drop-after::after { right: -10px; }
</style>
<div id="connection-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">
<?php foreach ($connections as $conn): ?> <?php foreach ($connections as $conn): ?>
<div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer" title="Abruf starten"> <div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer;position:relative" title="Abruf starten">
<span class="drag-handle" draggable="true"
title="Ziehen zum Sortieren"
style="position:absolute;top:.4rem;left:.4rem;color:var(--color-muted);cursor:grab;user-select:none;padding:.15rem .35rem;font-size:.95rem;line-height:1">⠿</span>
<div class="card-body"> <div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;padding-left:1rem">
<div> <div>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<?php if (!empty($conn['kuerzel'])): ?> <?php if (!empty($conn['kuerzel'])): ?>
@ -574,9 +592,118 @@ $connectionsJson = json_encode(array_map(fn($c) => [
btnTanSubmit.addEventListener('click', submitTan); btnTanSubmit.addEventListener('click', submitTan);
tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') 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 => { document.querySelectorAll('.btn-run-connection').forEach(card => {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (didDrag) { didDrag = false; return; }
if (e.target.closest('.btn-edit-connection')) return; if (e.target.closest('.btn-edit-connection')) return;
if (e.target.closest('.drag-handle')) return;
const id = card.dataset.id; const id = card.dataset.id;
currentSourceId = id; currentSourceId = id;