@@ -0,0 +1,560 @@
{% extends "base.html" %}
{% block title %}Batteries — Battery Tracker{% endblock %}
{% block content %}
< h1 > Batteries< / h1 >
< 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 = "last-charged" onchange = "toggleCol(this)" style = "margin-right:0.4rem;" > Last Charged< / label >
< label style = "display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;" > < input type = "checkbox" data-col = "health" onchange = "toggleCol(this)" style = "margin-right:0.4rem;" > Health %< / label >
< 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;margin-bottom:0.3rem;font-size:0.875rem;" > < input type = "checkbox" data-col = "cycles" onchange = "toggleCol(this)" style = "margin-right:0.4rem;" > Charge Cycles< / label >
{% if ha_enabled %}< label style = "display:block;cursor:pointer;font-size:0.875rem;" > < input type = "checkbox" data-col = "ha-pct" onchange = "toggleCol(this)" style = "margin-right:0.4rem;" > Battery %< / label > {% endif %}
< / 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 = "active" selected > Active (non-retired)< / option >
< 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;" >
< input type = "number" id = "filter-range-from" oninput = "applyFilters()" placeholder = "# from"
style = "padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;" >
< input type = "number" id = "filter-range-to" oninput = "applyFilters()" placeholder = "# to"
style = "padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;" >
< button type = "button" id = "select-all-btn" onclick = "mobileSelectAll()" class = "btn btn-sm btn-secondary" style = "display:none;" > Select all< / button >
< 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;align-items:center;gap:0.5rem;flex-wrap:wrap;position:sticky;top:0;z-index:90;box-shadow:0 2px 6px rgba(0,0,0,.08);" >
< 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 = "button"
onclick = "bulkActionConfirm(this, 'Permanently delete selected batteries?', 'Delete', 'btn-danger')" > 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_with_slots %}
< 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 = "button"
onclick = "confirmInstallDevice(this)" > Install in device< / button >
< / span >
< span style = "display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;" >
< input type = "date" name = "charged_date" id = "bulk-charged-date" value = "{{ today.isoformat() }}"
style = "padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;" >
< label style = "font-size:0.85rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;" >
< input type = "checkbox" name = "increment_cycles" id = "bulk-increment-cycles" value = "1" checked >
+cycle
< / label >
< button class = "btn btn-sm btn-primary" name = "action" value = "log_charged" type = "submit"
onclick = "return validateBulkCharge()" > Log Charged< / 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 data-sortable = "label" > Label< / th >
< th data-sortable = "brand" > Brand< / th >
< th > Size< / th >
< th class = "col-last-charged" style = "display:none;" data-sortable = "last-charged" > Last Charged< / th >
< th class = "col-health" style = "display:none;" data-sortable = "health" > Health< / 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;" data-sortable = "cycles" > Cycles< / th >
{% if ha_enabled %}< th class = "col-ha-pct" style = "display:none;" data-sortable = "ha-pct" > Bat %< / th > {% endif %}
< th data-sortable = "status" > 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 '' }}" data-label = "{{ b.label }}" >
< td data-label = "" > < input type = "checkbox" name = "battery_ids" value = "{{ b.id }}" class = "row-cb" > < / td >
< td data-label = "Label" data-sort-col = "label" data-sort = "{{ b.label }}" > < a href = "{{ url_for('battery_detail', battery_id=b.id) }}" > < strong > {{ b.label }}< / strong > < / a > < / td >
< td data-label = "Brand" data-sort-col = "brand" data-sort = "{{ b.brand }}" > {{ b.brand }}< / td >
< td data-label = "Size" > {{ b.size or '—' }}< / td >
< td data-label = "Last Charged" class = "col-last-charged" style = "display:none;"
data-sort-col = "last-charged" data-sort = "{{ last_charged_map.get(b.id, '') }}"
data-charged = "{{ last_charged_map.get(b.id, '') }}" > < / td >
< td data-label = "Health" class = "col-health" style = "display:none;"
data-sort-col = "health" data-sort = "{% if b.tested_capacity_mah and b.capacity_mah %}{{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}{% endif %}" >
{% if b.tested_capacity_mah and b.capacity_mah %}
{% set hp = (b.tested_capacity_mah / b.capacity_mah * 100)|int %}
< span class = "badge {% if hp >= 80 %}badge-available{% elif hp >= 60 %}badge-warning{% else %}badge-retired{% endif %}" > {{ hp }}%< / span >
{% else %}< span class = "text-muted" > —< / span > {% endif %}
< / 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;"
data-sort-col = "cycles" data-sort = "{{ b.charge_cycles or '' }}" > {{ b.charge_cycles or '—' }}< / td >
{% if ha_enabled %}
< td data-label = "Bat %" class = "col-ha-pct" style = "display:none;"
data-sort-col = "ha-pct" data-sort = "{{ b.battery_percentage if b.battery_percentage is not none else '' }}" >
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 and b . status ! = ' retired ' % }
< span class = "badge badge-warning" title = "Low — consider replacing" > ⚠ {{ b.battery_percentage }}%< / span >
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
< / td >
{% endif %}
< td data-label = "Status" data-sort-col = "status" data-sort = "{{ b.status }}" >
< span class = "badge badge-{{ b.status }}" > {{ b.status|capitalize }}< / span >
< / td >
< td data-label = "Assigned To" >
{% if b.device %}
{% if b.device.parent %}
< a href = "{{ url_for('device_detail', device_id=b.device.parent.id) }}" > {{ b.device.parent.name }}< / a >
< span class = "text-muted" > / < / span >
{% endif %}
< 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_with_slots %}
< option value = "{{ d.id }}" > {{ 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 >
< 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 >
< select id = "page-size-select" onchange = "PAGE_SIZE=+this.value||Infinity;currentPage=1;applyPagination();"
style = "padding:0.2rem 0.4rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;" >
< option value = "10" > 10 / page< / option >
< option value = "25" selected > 25 / page< / option >
< option value = "50" > 50 / page< / option >
< option value = "100" > 100 / page< / option >
< option value = "" > All< / option >
< / select >
< / div >
< / form >
< / div >
< script >
var CSRF _TOKEN = '{{ csrf_token() }}' ;
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 = 25 ;
var currentPage = 1 ;
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 ;
if ( selectAllBtn ) {
selectAllBtn . style . display = vis . length > 0 ? '' : 'none' ;
selectAllBtn . textContent = ( vis . length > 0 && visChecked . length === vis . length )
? 'Deselect all' : 'Select all' ;
}
}
function mobileSelectAll ( ) {
var vis = visibleCbs ( ) ;
var allChecked = vis . length > 0 && vis . every ( function ( cb ) { return cb . checked ; } ) ;
vis . forEach ( function ( cb ) { cb . checked = ! allChecked ; } ) ;
updateToolbar ( ) ;
}
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 rangeFromVal = document . getElementById ( 'filter-range-from' ) . value . trim ( ) ;
var rangeToVal = document . getElementById ( 'filter-range-to' ) . value . trim ( ) ;
var rangeMin = rangeFromVal !== '' ? parseInt ( rangeFromVal , 10 ) : null ;
var rangeMax = rangeToVal !== '' ? parseInt ( rangeToVal , 10 ) : null ;
var anyActive = ( status && status !== 'active' ) || brand || size || storage || text || rangeFromVal || rangeToVal ;
document . getElementById ( 'filter-reset' ) . style . display = anyActive ? '' : 'none' ;
var rows = document . querySelectorAll ( 'tbody tr[data-brand]' ) ;
var filtered = 0 ;
rows . forEach ( function ( row ) {
var show = true ;
if ( status === 'active' ) {
if ( row . dataset . status === 'retired' ) show = false ;
} else if ( status ) {
if ( 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 ;
if ( rangeMin !== null || rangeMax !== null ) {
var lbl = row . dataset . label || '' ;
var m = lbl . match ( /(\d+)\s*$/ ) ;
var n = m ? parseInt ( m [ 1 ] , 10 ) : null ;
if ( n === null ) show = false ;
else if ( rangeMin !== null && n < rangeMin ) show = false ;
else if ( rangeMax !== null && n > rangeMax ) show = false ;
}
row . dataset . filteredOut = show ? '' : '1' ;
if ( show ) filtered ++ ;
} ) ;
var fc = document . getElementById ( 'filter-count' ) ;
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 ( ) ;
}
function confirmInstallDevice ( btn ) {
var deviceSel = document . getElementById ( 'bulk-device-select' ) ;
if ( ! deviceSel . value ) { deviceSel . focus ( ) ; return ; }
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 ;
showConfirm (
n + ' selected batter' + ( n === 1 ? 'y is' : 'ies are' ) +
' already installed elsewhere. Unassign and move to the selected device?' ,
function ( ) { submitWithAction ( btn ) ; } ,
'Move' , 'btn-warning'
) ;
} else {
submitWithAction ( btn ) ;
}
}
function bulkActionConfirm ( btn , msg , okLabel , okClass ) {
showConfirm ( msg , function ( ) { submitWithAction ( btn ) ; } , okLabel , okClass ) ;
}
function submitWithAction ( btn ) {
var form = btn . form || document . getElementById ( 'bulk-form' ) ;
var inp = document . createElement ( 'input' ) ;
inp . type = 'hidden' ; inp . name = btn . name ; inp . value = btn . value ;
form . appendChild ( inp ) ;
form . submit ( ) ;
}
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 deviceInp = document . createElement ( 'input' ) ;
deviceInp . type = 'hidden' ; deviceInp . name = 'device_id' ; deviceInp . value = sel . value ;
f . appendChild ( deviceInp ) ;
var csrfInp = document . createElement ( 'input' ) ;
csrfInp . type = 'hidden' ; csrfInp . name = 'csrf_token' ; csrfInp . value = CSRF _TOKEN ;
f . appendChild ( csrfInp ) ;
document . body . appendChild ( f ) ;
f . submit ( ) ;
}
function resetFilters ( ) {
document . getElementById ( 'filter-status' ) . value = 'active' ;
[ 'filter-brand' , 'filter-size' , 'filter-storage' ] . forEach ( function ( id ) {
document . getElementById ( id ) . value = '' ;
} ) ;
document . getElementById ( 'filter-text' ) . value = '' ;
document . getElementById ( 'filter-range-from' ) . value = '' ;
document . getElementById ( 'filter-range-to' ) . 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 ;
updateToolbar ( ) ;
// Column picker
var COL _KEY = 'battery_cols' ;
var ALL _COLS = [ 'last-charged' , 'health' , 'chemistry' , 'capacity' , 'storage' , 'purchase' , 'cycles' { % if ha _enabled % } , 'ha-pct' { % endif % } ] ;
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 ) ; }
}
} ) ;
} ( ) ) ;
// Relative age for "Last Charged" column
function relAge ( dateStr ) {
if ( ! dateStr ) return { text : 'Never' , cls : 'text-danger' } ;
var days = Math . floor ( ( Date . now ( ) - new Date ( dateStr + 'T00:00:00' ) ) / 86400000 ) ;
if ( days <= 0 ) return { text : 'Today' , cls : '' } ;
if ( days === 1 ) return { text : 'Yesterday' , cls : '' } ;
if ( days < 14 ) return { text : days + ' days ago' , cls : '' } ;
if ( days < 60 ) return { text : Math . floor ( days / 7 ) + ' wks ago' , cls : days > 30 ? 'text-warning' : '' } ;
if ( days < 365 ) return { text : Math . floor ( days / 30 ) + ' mo ago' , cls : days > 180 ? 'text-danger' : 'text-warning' } ;
return { text : Math . floor ( days / 365 ) + ' yr ago' , cls : 'text-danger' } ;
}
document . querySelectorAll ( 'td[data-charged]' ) . forEach ( function ( td ) {
var r = relAge ( td . dataset . charged ) ;
td . innerHTML = '<span class="' + r . cls + '">' + r . text + '</span>' ;
} ) ;
// Sortable columns
var _sortCol = null , _sortDir = 1 ;
var _origOrder = null ;
function _captureOrder ( ) {
if ( ! _origOrder ) {
_origOrder = Array . from ( document . querySelectorAll ( 'tbody tr[data-brand]' ) ) ;
}
}
document . querySelectorAll ( 'th[data-sortable]' ) . forEach ( function ( th ) {
th . style . cursor = 'pointer' ;
th . style . userSelect = 'none' ;
var ind = document . createElement ( 'span' ) ;
ind . className = 'sort-ind' ;
ind . style . fontSize = '0.7rem' ;
th . appendChild ( ind ) ;
th . addEventListener ( 'click' , function ( ) {
var col = th . dataset . sortable ;
if ( _sortCol === col ) {
_sortDir = _sortDir === 1 ? - 1 : 0 ;
} else {
_sortCol = col ; _sortDir = 1 ;
}
document . querySelectorAll ( 'th[data-sortable] .sort-ind' ) . forEach ( function ( s ) { s . textContent = '' ; } ) ;
if ( _sortDir === 0 ) {
_sortCol = null ; _sortDir = 1 ;
_captureOrder ( ) ;
var tbody = document . querySelector ( 'tbody' ) ;
_origOrder . forEach ( function ( r ) { tbody . appendChild ( r ) ; } ) ;
applyPagination ( ) ;
return ;
}
ind . textContent = _sortDir === 1 ? ' ▲' : ' ▼' ;
_captureOrder ( ) ;
var tbody = document . querySelector ( 'tbody' ) ;
var rows = Array . from ( tbody . querySelectorAll ( 'tr[data-brand]' ) ) ;
rows . sort ( function ( a , b ) {
var at = a . querySelector ( 'td[data-sort-col="' + col + '"]' ) ;
var bt = b . querySelector ( 'td[data-sort-col="' + col + '"]' ) ;
var av = at ? ( at . dataset . sort || '' ) : '' ;
var bv = bt ? ( bt . dataset . sort || '' ) : '' ;
// empty values always sort last
if ( ! av && ! bv ) return 0 ;
if ( ! av ) return 1 ;
if ( ! bv ) return - 1 ;
var an = parseFloat ( av ) , bn = parseFloat ( bv ) ;
if ( ! isNaN ( an ) && ! isNaN ( bn ) ) return ( an - bn ) * _sortDir ;
return av . localeCompare ( bv ) * _sortDir ;
} ) ;
rows . forEach ( function ( r ) { tbody . appendChild ( r ) ; } ) ;
applyPagination ( ) ;
} ) ;
} ) ;
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 validateBulkCharge ( ) {
var d = document . getElementById ( 'bulk-charged-date' ) ;
if ( ! d . value ) { d . focus ( ) ; return false ; }
return true ;
}
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 ;
}
}
applyFilters ( ) ;
< / script >
{% endblock %}