Add pagination, device detail unassign-all, and batch install with slot limit
This commit is contained in:
@@ -958,8 +958,45 @@ def create_app(config_object="config"):
|
|||||||
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
|
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
nxt = request.form.get("next", "")
|
||||||
|
if nxt.startswith("/"):
|
||||||
|
return redirect(nxt)
|
||||||
return redirect(url_for("device_list"))
|
return redirect(url_for("device_list"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Devices — batch install specific batteries
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@app.route("/device/<int:device_id>/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
|
# Export
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -300,6 +300,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pagination-bar" style="display:flex;align-items:center;gap:0.75rem;margin-top:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button id="prev-page" class="btn btn-sm btn-secondary" type="button"
|
||||||
|
onclick="currentPage--; applyPagination();" disabled>← Prev</button>
|
||||||
|
<span id="page-info" style="color:var(--text-muted);font-size:0.9rem;"></span>
|
||||||
|
<button id="next-page" class="btn btn-sm btn-secondary" type="button"
|
||||||
|
onclick="currentPage++; applyPagination();" disabled>Next →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +316,8 @@ var selectAll = document.getElementById('select-all');
|
|||||||
var toolbar = document.getElementById('bulk-toolbar');
|
var toolbar = document.getElementById('bulk-toolbar');
|
||||||
var countEl = document.getElementById('selected-count');
|
var countEl = document.getElementById('selected-count');
|
||||||
var selectAllBtn = document.getElementById('select-all-btn');
|
var selectAllBtn = document.getElementById('select-all-btn');
|
||||||
|
var PAGE_SIZE = 50;
|
||||||
|
var currentPage = 1;
|
||||||
|
|
||||||
function visibleCbs() {
|
function visibleCbs() {
|
||||||
return Array.prototype.filter.call(
|
return Array.prototype.filter.call(
|
||||||
@@ -358,7 +367,7 @@ function applyFilters() {
|
|||||||
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
|
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
|
||||||
|
|
||||||
var rows = document.querySelectorAll('tbody tr[data-brand]');
|
var rows = document.querySelectorAll('tbody tr[data-brand]');
|
||||||
var visible = 0;
|
var filtered = 0;
|
||||||
rows.forEach(function(row) {
|
rows.forEach(function(row) {
|
||||||
var show = true;
|
var show = true;
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
@@ -370,12 +379,34 @@ function applyFilters() {
|
|||||||
if (size && row.dataset.size !== size) show = false;
|
if (size && row.dataset.size !== size) show = false;
|
||||||
if (storage && row.dataset.storage !== storage) show = false;
|
if (storage && row.dataset.storage !== storage) show = false;
|
||||||
if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false;
|
if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false;
|
||||||
row.style.display = show ? '' : 'none';
|
row.dataset.filteredOut = show ? '' : '1';
|
||||||
if (show) visible++;
|
if (show) filtered++;
|
||||||
});
|
});
|
||||||
|
|
||||||
var fc = document.getElementById('filter-count');
|
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();
|
updateToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +545,7 @@ document.querySelectorAll('th[data-sortable]').forEach(function(th) {
|
|||||||
_captureOrder();
|
_captureOrder();
|
||||||
var tbody = document.querySelector('tbody');
|
var tbody = document.querySelector('tbody');
|
||||||
_origOrder.forEach(function(r) { tbody.appendChild(r); });
|
_origOrder.forEach(function(r) { tbody.appendChild(r); });
|
||||||
|
applyPagination();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ind.textContent = _sortDir === 1 ? ' \u25b2' : ' \u25bc';
|
ind.textContent = _sortDir === 1 ? ' \u25b2' : ' \u25bc';
|
||||||
@@ -534,6 +566,7 @@ document.querySelectorAll('th[data-sortable]').forEach(function(th) {
|
|||||||
return av.localeCompare(bv) * _sortDir;
|
return av.localeCompare(bv) * _sortDir;
|
||||||
});
|
});
|
||||||
rows.forEach(function(r) { tbody.appendChild(r); });
|
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||||
|
applyPagination();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -197,27 +197,73 @@ function addInstallRow() {
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No batteries installed.</p>
|
<p class="text-muted">No batteries installed.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if installed %}
|
||||||
|
<form class="inline" method="post"
|
||||||
|
action="{{ url_for('device_unassign_all', device_id=device.id) }}"
|
||||||
|
data-confirm="Unassign all batteries from {{ device.name }}?"
|
||||||
|
data-confirm-ok="Unassign" data-confirm-class="btn-warning"
|
||||||
|
style="margin-top:0.5rem;">
|
||||||
|
<input type="hidden" name="next" value="{{ url_for('device_detail', device_id=device.id) }}#installed">
|
||||||
|
<button class="btn btn-sm btn-warning" type="submit">Unassign All</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Install Specific Battery{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2>
|
<h2>Install Specific Batteries{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2>
|
||||||
{% if available_batteries %}
|
{% if available_batteries %}
|
||||||
<form method="post" action="{{ url_for('device_install_one', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('device_install_batch', device_id=device.id) }}">
|
||||||
<div class="form-group">
|
<div style="margin-bottom:0.5rem;">
|
||||||
<label for="battery_id">Battery</label>
|
<label style="font-size:0.85rem;cursor:pointer;">
|
||||||
<select name="battery_id" id="battery_id">
|
<input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all
|
||||||
<option value="">— select —</option>
|
</label>
|
||||||
{% for b in available_batteries %}
|
|
||||||
<option value="{{ b.id }}">{{ b.label }} — {{ b.brand }}
|
|
||||||
{%- if b.size %} {{ b.size }}{% endif %}
|
|
||||||
{%- if b.notes %} ({{ b.notes }}){% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Install</button>
|
<div style="max-height:200px;overflow-y:auto;border:1px solid #cbd5e1;border-radius:4px;padding:0.4rem 0.6rem;">
|
||||||
|
{% for b in available_batteries %}
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.9rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="battery_ids" value="{{ b.id }}">
|
||||||
|
{{ b.label }} — {{ b.brand }}{% if b.battery_percentage is not none %} ({{ b.battery_percentage }}%){% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" type="submit" style="margin-top:0.5rem;">Install Selected</button>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
var FREE_SLOTS = {{ device.battery_slots - device.installed_count() }};
|
||||||
|
var checkedQueue = [];
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="battery_ids"]').forEach(function(cb) {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
if (cb.checked) {
|
||||||
|
checkedQueue.push(cb);
|
||||||
|
if (checkedQueue.length > FREE_SLOTS) {
|
||||||
|
var oldest = checkedQueue.shift();
|
||||||
|
oldest.checked = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkedQueue = checkedQueue.filter(function(c) { return c !== cb; });
|
||||||
|
}
|
||||||
|
document.getElementById('select-all-avail').checked = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleAllAvail(masterCb) {
|
||||||
|
var all = Array.from(document.querySelectorAll('input[name="battery_ids"]'));
|
||||||
|
if (masterCb.checked) {
|
||||||
|
checkedQueue = [];
|
||||||
|
all.forEach(function(c) { c.checked = false; });
|
||||||
|
all.slice(0, FREE_SLOTS).forEach(function(c) { c.checked = true; checkedQueue.push(c); });
|
||||||
|
if (all.length > FREE_SLOTS) masterCb.checked = false;
|
||||||
|
} else {
|
||||||
|
all.forEach(function(c) { c.checked = false; });
|
||||||
|
checkedQueue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No available batteries.</p>
|
<p class="text-muted">No compatible batteries available.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -110,11 +110,21 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dev-pagination-bar" style="display:flex;align-items:center;gap:0.75rem;margin-top:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button id="dev-prev-page" class="btn btn-sm btn-secondary" type="button"
|
||||||
|
onclick="devCurrentPage--; applyDevicePagination();" disabled>← Prev</button>
|
||||||
|
<span id="dev-page-info" style="color:var(--text-muted);font-size:0.9rem;"></span>
|
||||||
|
<button id="dev-next-page" class="btn btn-sm btn-secondary" type="button"
|
||||||
|
onclick="devCurrentPage++; applyDevicePagination();" disabled>Next →</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
|
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var DEV_PAGE_SIZE = 25;
|
||||||
|
var devCurrentPage = 1;
|
||||||
|
|
||||||
function applyDeviceFilters() {
|
function applyDeviceFilters() {
|
||||||
var typeVal = document.getElementById('filter-type').value;
|
var typeVal = document.getElementById('filter-type').value;
|
||||||
var batterySizeVal = document.getElementById('filter-battery-size').value;
|
var batterySizeVal = document.getElementById('filter-battery-size').value;
|
||||||
@@ -122,7 +132,7 @@ function applyDeviceFilters() {
|
|||||||
var fillVal = document.getElementById('filter-fill').value;
|
var fillVal = document.getElementById('filter-fill').value;
|
||||||
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
|
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
|
||||||
var rows = document.querySelectorAll('tbody tr[data-name]');
|
var rows = document.querySelectorAll('tbody tr[data-name]');
|
||||||
var visible = 0;
|
var filtered = 0;
|
||||||
rows.forEach(function(row) {
|
rows.forEach(function(row) {
|
||||||
var rowType = row.dataset.type || '';
|
var rowType = row.dataset.type || '';
|
||||||
var rowBatterySize = row.dataset.batterySize || '';
|
var rowBatterySize = row.dataset.batterySize || '';
|
||||||
@@ -137,13 +147,34 @@ function applyDeviceFilters() {
|
|||||||
rowType.toLowerCase().includes(textVal) ||
|
rowType.toLowerCase().includes(textVal) ||
|
||||||
rowBatterySize.toLowerCase().includes(textVal) ||
|
rowBatterySize.toLowerCase().includes(textVal) ||
|
||||||
rowLocation.toLowerCase().includes(textVal));
|
rowLocation.toLowerCase().includes(textVal));
|
||||||
row.style.display = show ? '' : 'none';
|
row.dataset.filteredOut = show ? '' : '1';
|
||||||
if (show) visible++;
|
if (show) filtered++;
|
||||||
});
|
});
|
||||||
var active = typeVal || batterySizeVal || locationVal || fillVal || textVal;
|
var active = typeVal || batterySizeVal || locationVal || fillVal || textVal;
|
||||||
document.getElementById('device-filter-reset').style.display = active ? '' : 'none';
|
document.getElementById('device-filter-reset').style.display = active ? '' : 'none';
|
||||||
document.getElementById('device-filter-count').textContent =
|
document.getElementById('device-filter-count').textContent =
|
||||||
active ? (visible + ' of ' + rows.length + ' shown') : '';
|
active ? (filtered + ' of ' + rows.length + ' shown') : '';
|
||||||
|
devCurrentPage = 1;
|
||||||
|
applyDevicePagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDevicePagination() {
|
||||||
|
var allRows = Array.from(document.querySelectorAll('tbody tr[data-name]'));
|
||||||
|
var visible = allRows.filter(function(r) { return !r.dataset.filteredOut; });
|
||||||
|
var totalPages = Math.max(1, Math.ceil(visible.length / DEV_PAGE_SIZE));
|
||||||
|
if (devCurrentPage > totalPages) devCurrentPage = totalPages;
|
||||||
|
var start = (devCurrentPage - 1) * DEV_PAGE_SIZE;
|
||||||
|
|
||||||
|
allRows.forEach(function(r) { r.style.display = 'none'; });
|
||||||
|
visible.slice(start, start + DEV_PAGE_SIZE).forEach(function(r) { r.style.display = ''; });
|
||||||
|
|
||||||
|
var info = document.getElementById('dev-page-info');
|
||||||
|
var prev = document.getElementById('dev-prev-page');
|
||||||
|
var next = document.getElementById('dev-next-page');
|
||||||
|
if (info) info.textContent = visible.length > DEV_PAGE_SIZE
|
||||||
|
? 'Page ' + devCurrentPage + ' of ' + totalPages : '';
|
||||||
|
if (prev) prev.disabled = devCurrentPage <= 1;
|
||||||
|
if (next) next.disabled = devCurrentPage >= totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDeviceFilters() {
|
function resetDeviceFilters() {
|
||||||
@@ -154,5 +185,7 @@ function resetDeviceFilters() {
|
|||||||
document.getElementById('filter-device-text').value = '';
|
document.getElementById('filter-device-text').value = '';
|
||||||
applyDeviceFilters();
|
applyDeviceFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyDeviceFilters();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -807,3 +807,32 @@ def test_full_roundtrip_export_import(client):
|
|||||||
content_type="multipart/form-data")
|
content_type="multipart/form-data")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Import Results" in resp.data
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user