diff --git a/app.py b/app.py index a832bfd..f0810a2 100644 --- a/app.py +++ b/app.py @@ -958,8 +958,45 @@ def create_app(config_object="config"): f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.", "success", ) + nxt = request.form.get("next", "") + if nxt.startswith("/"): + return redirect(nxt) return redirect(url_for("device_list")) + # ------------------------------------------------------------------ # + # Devices — batch install specific batteries + # ------------------------------------------------------------------ # + + @app.route("/device//install-batch", methods=["POST"]) + def device_install_batch(device_id): + device = db.get(Device, device_id) + if device is None: + abort(404) + battery_ids = request.form.getlist("battery_ids") + free = device.battery_slots - device.installed_count() + if not battery_ids: + flash("No batteries selected.", "warning") + return redirect(url_for("device_detail", device_id=device_id)) + installed = 0 + for bid in battery_ids: + if installed >= free: + break + battery = db.get(Battery, int(bid)) + if not battery or battery.status != "available": + continue + battery.status = "installed" + battery.device_id = device.id + installed += 1 + db.commit() + if installed: + flash( + f"Installed {installed} batter{'y' if installed == 1 else 'ies'} into {device.name}.", + "success", + ) + else: + flash("No batteries were installed.", "warning") + return redirect(url_for("device_detail", device_id=device_id)) + # ------------------------------------------------------------------ # # Export # ------------------------------------------------------------------ # diff --git a/templates/dashboard.html b/templates/dashboard.html index 6acee37..6f004c2 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -300,6 +300,13 @@ +
+ + + +
@@ -309,6 +316,8 @@ var selectAll = document.getElementById('select-all'); var toolbar = document.getElementById('bulk-toolbar'); var countEl = document.getElementById('selected-count'); var selectAllBtn = document.getElementById('select-all-btn'); +var PAGE_SIZE = 50; +var currentPage = 1; function visibleCbs() { return Array.prototype.filter.call( @@ -358,7 +367,7 @@ function applyFilters() { document.getElementById('filter-reset').style.display = anyActive ? '' : 'none'; var rows = document.querySelectorAll('tbody tr[data-brand]'); - var visible = 0; + var filtered = 0; rows.forEach(function(row) { var show = true; if (status === 'active') { @@ -370,12 +379,34 @@ function applyFilters() { if (size && row.dataset.size !== size) show = false; if (storage && row.dataset.storage !== storage) show = false; if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false; - row.style.display = show ? '' : 'none'; - if (show) visible++; + row.dataset.filteredOut = show ? '' : '1'; + if (show) filtered++; }); var fc = document.getElementById('filter-count'); - fc.textContent = anyActive ? (visible + ' of ' + rows.length + ' shown') : ''; + fc.textContent = anyActive ? (filtered + ' of ' + rows.length + ' shown') : ''; + currentPage = 1; + applyPagination(); +} + +function applyPagination() { + var allRows = Array.from(document.querySelectorAll('tbody tr[data-brand]')); + var visible = allRows.filter(function(r) { return !r.dataset.filteredOut; }); + var totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)); + if (currentPage > totalPages) currentPage = totalPages; + var start = (currentPage - 1) * PAGE_SIZE; + + allRows.forEach(function(r) { r.style.display = 'none'; }); + visible.slice(start, start + PAGE_SIZE).forEach(function(r) { r.style.display = ''; }); + + var info = document.getElementById('page-info'); + var prev = document.getElementById('prev-page'); + var next = document.getElementById('next-page'); + if (info) info.textContent = visible.length > PAGE_SIZE + ? 'Page ' + currentPage + ' of ' + totalPages : ''; + if (prev) prev.disabled = currentPage <= 1; + if (next) next.disabled = currentPage >= totalPages; + updateToolbar(); } @@ -514,6 +545,7 @@ document.querySelectorAll('th[data-sortable]').forEach(function(th) { _captureOrder(); var tbody = document.querySelector('tbody'); _origOrder.forEach(function(r) { tbody.appendChild(r); }); + applyPagination(); return; } ind.textContent = _sortDir === 1 ? ' \u25b2' : ' \u25bc'; @@ -534,6 +566,7 @@ document.querySelectorAll('th[data-sortable]').forEach(function(th) { return av.localeCompare(bv) * _sortDir; }); rows.forEach(function(r) { tbody.appendChild(r); }); + applyPagination(); }); }); diff --git a/templates/device_detail.html b/templates/device_detail.html index d36b78a..2a1f02c 100644 --- a/templates/device_detail.html +++ b/templates/device_detail.html @@ -197,27 +197,73 @@ function addInstallRow() { {% else %}

No batteries installed.

{% endif %} + {% if installed %} +
+ + +
+ {% endif %}
-

Install Specific Battery{% if device.battery_size %} ({{ device.battery_size }} only){% endif %}

+

Install Specific Batteries{% if device.battery_size %} ({{ device.battery_size }} only){% endif %}

{% if available_batteries %} -
-
- - + +
+
- +
+ {% for b in available_batteries %} +
+ +
+ {% endfor %} +
+ + {% else %} -

No available batteries.

+

No compatible batteries available.

{% endif %}
diff --git a/templates/device_list.html b/templates/device_list.html index e077c84..9aab556 100644 --- a/templates/device_list.html +++ b/templates/device_list.html @@ -110,11 +110,21 @@
+
+ + + +
+ Add Device {% endblock %} diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index 5ed3c0e..e375339 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -807,3 +807,32 @@ def test_full_roundtrip_export_import(client): content_type="multipart/form-data") assert resp.status_code == 200 assert b"Import Results" in resp.data + + +def test_device_detail_unassign_all(seeded_client): + client = seeded_client + # install battery 1 into device 1 (2-slot AA device) + client.post("/device/1/install-one", data={"battery_id": "1"}) + # unassign all from device detail, redirecting back to device detail + resp = client.post( + "/device/1/unassign-all", + data={"next": "/device/1"}, + follow_redirects=True, + ) + assert resp.status_code == 200 + assert b"Unassigned" in resp.data + # device detail should now show 0 installed + assert b"0 / 2" in resp.data + + +def test_device_install_batch(seeded_client): + client = seeded_client + # batteries 1 (BrandX AA) and 2 (BrandY AA) are available; device 1 has 2 slots + resp = client.post( + "/device/1/install-batch", + data={"battery_ids": ["1", "2"]}, + follow_redirects=True, + ) + assert resp.status_code == 200 + assert b"Installed 2" in resp.data + assert b"2 / 2" in resp.data