Files
battery-tracker-app/templates/dashboard.html
T
iterminate 3bc897c1e5 Add device_type field, mobile-friendly improvements, and device filtering
- Device model: add device_type column (String 50, nullable)
- Device add/edit: type select with presets + custom entry
- Device detail: show type in info card; new Edit Device form
- Device list: Type column + client-side filter bar (type + text search)
- Mobile: card-style responsive tables on dashboard and device list,
  form-grid-2col collapse, larger tap targets, stacked form-actions,
  column picker viewport fix, filter bar full-width controls
- Assign page: larger radio touch targets (min-height 44px)
- 3 new acceptance tests for device_type (45 total)
2026-04-12 22:02:29 -05:00

371 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>
<span style="display:flex;gap:0.35rem;align-items:center;">
<select id="bulk-device-select" name="device_id"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select device —</option>
{% for d in devices %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="submit"
onclick="return confirmInstallDevice()">Install in device</button>
</span>
</div>
<div class="table-wrap">
<table class="responsive-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 data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
<td data-label="Brand">{{ b.brand }}</td>
<td data-label="Size">{{ b.size or '—' }}</td>
<td data-label="Chemistry" class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
<td data-label="Capacity" 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 data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
<td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
<td data-label="Status">
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td>
<td data-label="Assigned To">
{% 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 data-label="Actions" 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 confirmInstallDevice() {
var deviceSel = document.getElementById('bulk-device-select');
if (!deviceSel.value) { deviceSel.focus(); return false; }
var movers = Array.prototype.filter.call(
document.querySelectorAll('.row-cb:checked'),
function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
);
if (movers.length > 0) {
var n = movers.length;
return confirm(
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
' already installed elsewhere. Unassign and move to the selected device?'
);
}
return true;
}
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 %}