81e87d2fe2
- Dashboard: replace Assign link with device dropdown + arrow button for quick inline assignment without leaving the page - Device detail: replace hardcoded 4-row install form with 1 row + '+ Add brand' button that clones rows dynamically - Device detail: add 'Install Specific Battery' card with dropdown of all available batteries (label, brand, size, notes) via new /device/<id>/install-one route - Tests: 4 new acceptance tests covering dashboard quick-assign and install-one, including capacity enforcement on both paths (39 total)
343 lines
17 KiB
HTML
343 lines
17 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Dashboard — Battery Tracker{% endblock %}
|
||
|
||
{% block content %}
|
||
<h1>Battery Dashboard</h1>
|
||
|
||
{% set total = batteries|length %}
|
||
{% set available = batteries|selectattr('status','eq','available')|list|length %}
|
||
{% set installed = batteries|selectattr('status','eq','installed')|list|length %}
|
||
{% set retired = batteries|selectattr('status','eq','retired')|list|length %}
|
||
|
||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||
<div class="card" style="flex:1;min-width:120px;text-align:center;">
|
||
<div style="font-size:1.8rem;font-weight:700;">{{ total }}</div>
|
||
<div class="text-muted">Total</div>
|
||
</div>
|
||
<div class="card" style="flex:1;min-width:120px;text-align:center;">
|
||
<div style="font-size:1.8rem;font-weight:700;color:#166534;">{{ available }}</div>
|
||
<div class="text-muted">Available</div>
|
||
</div>
|
||
<div class="card" style="flex:1;min-width:120px;text-align:center;">
|
||
<div style="font-size:1.8rem;font-weight:700;color:#1e40af;">{{ installed }}</div>
|
||
<div class="text-muted">Installed</div>
|
||
</div>
|
||
<div class="card" style="flex:1;min-width:120px;text-align:center;">
|
||
<div style="font-size:1.8rem;font-weight:700;color:#64748b;">{{ retired }}</div>
|
||
<div class="text-muted">Retired</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;">
|
||
<div style="position:relative;">
|
||
<button type="button" id="col-picker-btn" class="btn btn-sm btn-secondary">Columns ▾</button>
|
||
<div id="col-picker-panel" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:0.75rem 1rem;z-index:100;box-shadow:0 4px 8px rgba(0,0,0,0.1);min-width:180px;">
|
||
<div style="font-weight:600;font-size:0.8rem;color:#64748b;margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">Show columns</div>
|
||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="chemistry" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Chemistry</label>
|
||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
|
||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
|
||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
|
||
<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
|
||
<select id="filter-status" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="">All Statuses</option>
|
||
<option value="available">Available</option>
|
||
<option value="installed">Installed</option>
|
||
<option value="retired">Retired</option>
|
||
</select>
|
||
<select id="filter-brand" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="">All Brands</option>
|
||
{% for b in batteries|map(attribute='brand')|unique|sort %}
|
||
<option value="{{ b }}">{{ b }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="filter-size" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="">All Sizes</option>
|
||
{% for s in batteries|map(attribute='size')|select|unique|sort %}
|
||
<option value="{{ s }}">{{ s }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="filter-storage" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="">All Locations</option>
|
||
{% for loc in storage_locations %}
|
||
<option value="{{ loc }}">{{ loc }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="text" id="filter-text" oninput="applyFilters()" placeholder="Search…"
|
||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;">
|
||
<button type="button" onclick="resetFilters()" class="btn btn-sm btn-secondary" id="filter-reset" style="display:none;">✕ Reset</button>
|
||
<span id="filter-count" style="font-size:0.8rem;color:#64748b;"></span>
|
||
</div>
|
||
|
||
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
|
||
|
||
<div id="bulk-toolbar" style="display:none;margin-bottom:0.75rem;padding:0.6rem 0.75rem;background:#f1f5f9;border-radius:6px;display:none;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
|
||
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
|
||
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
|
||
<button class="btn btn-sm btn-danger" name="action" value="delete" type="submit"
|
||
onclick="return confirm('Permanently delete selected batteries?')">Delete</button>
|
||
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
|
||
<input type="hidden" name="field_name" id="bulk-field-name" value="storage_location">
|
||
<select id="bulk-field-select" onchange="updateBulkField(this)"
|
||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="storage_location">Storage Location</option>
|
||
<option value="brand">Brand</option>
|
||
</select>
|
||
<!-- Storage Location value -->
|
||
<span id="bulk-val-storage_location" style="display:flex;gap:0.25rem;align-items:center;">
|
||
<select id="bulk-storage-select" onchange="bulkStorageChanged(this)"
|
||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||
<option value="">— select —</option>
|
||
{% for loc in storage_locations|default([]) %}
|
||
<option value="{{ loc }}">{{ loc }}</option>
|
||
{% endfor %}
|
||
<option value="__new__">➕ New location…</option>
|
||
</select>
|
||
<input type="text" id="bulk-storage-text"
|
||
style="display:none;padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;"
|
||
placeholder="Type location">
|
||
<input type="hidden" name="field_value" id="bulk-field-value-storage" value="">
|
||
</span>
|
||
<!-- Brand value -->
|
||
<span id="bulk-val-brand" style="display:none;">
|
||
<input type="text" id="bulk-brand-text" oninput="document.getElementById('bulk-field-value-brand').value=this.value"
|
||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;"
|
||
placeholder="New brand name">
|
||
<input type="hidden" name="field_value" id="bulk-field-value-brand" value="">
|
||
</span>
|
||
<button class="btn btn-sm btn-primary" name="action" value="set_field" type="submit">Apply</button>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
||
<th>Label</th>
|
||
<th>Brand</th>
|
||
<th>Size</th>
|
||
<th class="col-chemistry" style="display:none;">Chemistry</th>
|
||
<th class="col-capacity" style="display:none;">Capacity</th>
|
||
<th class="col-storage" style="display:none;">Storage</th>
|
||
<th class="col-purchase" style="display:none;">Purchase Date</th>
|
||
<th class="col-cycles" style="display:none;">Cycles</th>
|
||
<th>Status</th>
|
||
<th>Assigned To</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for b in batteries %}
|
||
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}">
|
||
<td><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
||
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
||
<td>{{ b.brand }}</td>
|
||
<td>{{ b.size or '—' }}</td>
|
||
<td class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
|
||
<td class="col-capacity" style="display:none;">
|
||
{% if b.capacity_mah %}
|
||
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
|
||
{% else %}{{ b.capacity_mah }} mAh{% endif %}
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
<td class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
||
<td class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
||
<td class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
|
||
<td>
|
||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||
</td>
|
||
<td>
|
||
{% if b.device %}
|
||
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
||
{% if b.device.has_mixed_brands() %}
|
||
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
|
||
{% endif %}
|
||
{% else %}
|
||
<span class="text-muted">—</span>
|
||
{% endif %}
|
||
</td>
|
||
<td style="white-space:nowrap;">
|
||
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
|
||
|
||
{% if b.is_available() %}
|
||
<select id="qas-{{ b.id }}"
|
||
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
|
||
<option value="">— assign —</option>
|
||
{% for d in devices %}
|
||
<option value="{{ d.id }}"
|
||
{% if d.installed_count() >= d.battery_slots %}disabled{% endif %}>
|
||
{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button type="button" class="btn btn-sm btn-primary"
|
||
onclick="quickAssign('{{ url_for('battery_assign', battery_id=b.id) }}', {{ b.id }})">→</button>
|
||
{% endif %}
|
||
|
||
{% if b.is_installed() %}
|
||
<button class="btn btn-sm btn-warning" type="submit"
|
||
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
|
||
{% endif %}
|
||
|
||
{% if not b.is_retired() %}
|
||
<button class="btn btn-sm btn-secondary" type="submit"
|
||
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="12" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
var selectAll = document.getElementById('select-all');
|
||
var toolbar = document.getElementById('bulk-toolbar');
|
||
var countEl = document.getElementById('selected-count');
|
||
|
||
function visibleCbs() {
|
||
return Array.prototype.filter.call(
|
||
document.querySelectorAll('.row-cb'),
|
||
function(cb) { return cb.closest('tr').style.display !== 'none'; }
|
||
);
|
||
}
|
||
|
||
function updateToolbar() {
|
||
var checked = document.querySelectorAll('.row-cb:checked');
|
||
var vis = visibleCbs();
|
||
var n = checked.length;
|
||
toolbar.style.display = n > 0 ? 'flex' : 'none';
|
||
countEl.textContent = n + ' selected';
|
||
var visChecked = vis.filter(function(cb) { return cb.checked; });
|
||
selectAll.indeterminate = visChecked.length > 0 && visChecked.length < vis.length;
|
||
selectAll.checked = vis.length > 0 && visChecked.length === vis.length;
|
||
}
|
||
|
||
document.querySelectorAll('.row-cb').forEach(function(cb) {
|
||
cb.addEventListener('change', updateToolbar);
|
||
});
|
||
selectAll.addEventListener('change', function() {
|
||
visibleCbs().forEach(function(cb) { cb.checked = selectAll.checked; });
|
||
updateToolbar();
|
||
});
|
||
|
||
function applyFilters() {
|
||
var status = document.getElementById('filter-status').value;
|
||
var brand = document.getElementById('filter-brand').value;
|
||
var size = document.getElementById('filter-size').value;
|
||
var storage = document.getElementById('filter-storage').value;
|
||
var text = document.getElementById('filter-text').value.trim().toLowerCase();
|
||
var anyActive = status || brand || size || storage || text;
|
||
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
|
||
|
||
var rows = document.querySelectorAll('tbody tr[data-brand]');
|
||
var visible = 0;
|
||
rows.forEach(function(row) {
|
||
var show = true;
|
||
if (status && row.dataset.status !== status) show = false;
|
||
if (brand && row.dataset.brand !== brand) show = false;
|
||
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++;
|
||
});
|
||
|
||
var fc = document.getElementById('filter-count');
|
||
fc.textContent = anyActive ? (visible + ' of ' + rows.length + ' shown') : '';
|
||
updateToolbar();
|
||
}
|
||
|
||
function quickAssign(action, batteryId) {
|
||
var sel = document.getElementById('qas-' + batteryId);
|
||
if (!sel.value) { sel.focus(); return; }
|
||
var f = document.createElement('form');
|
||
f.method = 'post'; f.action = action;
|
||
var inp = document.createElement('input');
|
||
inp.type = 'hidden'; inp.name = 'device_id'; inp.value = sel.value;
|
||
f.appendChild(inp);
|
||
document.body.appendChild(f);
|
||
f.submit();
|
||
}
|
||
|
||
function resetFilters() {
|
||
['filter-status','filter-brand','filter-size','filter-storage'].forEach(function(id) {
|
||
document.getElementById(id).value = '';
|
||
});
|
||
document.getElementById('filter-text').value = '';
|
||
applyFilters();
|
||
}
|
||
|
||
function updateBulkField(sel) {
|
||
var field = sel.value;
|
||
document.getElementById('bulk-field-name').value = field;
|
||
document.getElementById('bulk-val-storage_location').style.display = field === 'storage_location' ? 'flex' : 'none';
|
||
document.getElementById('bulk-val-brand').style.display = field === 'brand' ? 'flex' : 'none';
|
||
document.getElementById('bulk-field-value-storage').disabled = (field !== 'storage_location');
|
||
document.getElementById('bulk-field-value-brand').disabled = (field !== 'brand');
|
||
}
|
||
// initialise disabled state on page load
|
||
document.getElementById('bulk-field-value-brand').disabled = true;
|
||
|
||
// Column picker
|
||
var COL_KEY = 'battery_cols';
|
||
var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles'];
|
||
|
||
function toggleCol(cb) {
|
||
var col = cb.dataset.col;
|
||
document.querySelectorAll('.col-' + col).forEach(function(el) {
|
||
el.style.display = cb.checked ? '' : 'none';
|
||
});
|
||
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
|
||
prefs[col] = cb.checked;
|
||
localStorage.setItem(COL_KEY, JSON.stringify(prefs));
|
||
}
|
||
|
||
(function loadColPrefs() {
|
||
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
|
||
ALL_COLS.forEach(function(col) {
|
||
if (prefs[col]) {
|
||
var cb = document.querySelector('[data-col="' + col + '"]');
|
||
if (cb) { cb.checked = true; toggleCol(cb); }
|
||
}
|
||
});
|
||
}());
|
||
|
||
document.getElementById('col-picker-btn').addEventListener('click', function(e) {
|
||
var panel = document.getElementById('col-picker-panel');
|
||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||
e.stopPropagation();
|
||
});
|
||
document.addEventListener('click', function() {
|
||
document.getElementById('col-picker-panel').style.display = 'none';
|
||
});
|
||
|
||
function bulkStorageChanged(sel) {
|
||
var text = document.getElementById('bulk-storage-text');
|
||
var hidden = document.getElementById('bulk-field-value-storage');
|
||
if (sel.value === '__new__') {
|
||
text.style.display = '';
|
||
text.value = '';
|
||
text.oninput = function() { hidden.value = text.value; };
|
||
text.focus();
|
||
hidden.value = '';
|
||
} else {
|
||
text.style.display = 'none';
|
||
hidden.value = sel.value;
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|