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:
parent
995b52fbd3
commit
b42758eb6e
5 changed files with 168 additions and 4 deletions
7
migrations/071_bank_sources_sort_order.sql
Normal file
7
migrations/071_bank_sources_sort_order.sql
Normal 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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -30,11 +30,29 @@ $connectionsJson = json_encode(array_map(fn($c) => [
|
|||
</div>
|
||||
</div>
|
||||
<?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): ?>
|
||||
<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 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 style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<?php if (!empty($conn['kuerzel'])): ?>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue