Split dashboard into home page and battery list page
This commit is contained in:
@@ -97,7 +97,7 @@ def create_app(config_object="config"):
|
|||||||
poller.start()
|
poller.start()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Dashboard
|
# Home / Battery list
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
@app.route("/sw.js")
|
@app.route("/sw.js")
|
||||||
@@ -106,26 +106,14 @@ def create_app(config_object="config"):
|
|||||||
mimetype="application/javascript")
|
mimetype="application/javascript")
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def dashboard():
|
def home():
|
||||||
batteries = db.query(Battery).order_by(Battery.label).all()
|
batteries = db.query(Battery).all()
|
||||||
storage_locations = [
|
|
||||||
r[0] for r in db.query(Battery.storage_location)
|
|
||||||
.filter(Battery.storage_location.isnot(None))
|
|
||||||
.distinct().order_by(Battery.storage_location).all()
|
|
||||||
]
|
|
||||||
devices = db.query(Device).order_by(Device.name).all()
|
|
||||||
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
one_year_ago = (today - timedelta(days=365)).isoformat()
|
one_year_ago = (today - timedelta(days=365)).isoformat()
|
||||||
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
|
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
|
||||||
charges_last_year = (db.query(func.count(ChargeLog.id))
|
charges_last_year = (db.query(func.count(ChargeLog.id))
|
||||||
.filter(ChargeLog.charged_date >= one_year_ago)
|
.filter(ChargeLog.charged_date >= one_year_ago)
|
||||||
.scalar()) or 0
|
.scalar()) or 0
|
||||||
last_charged_map = {
|
|
||||||
r[0]: r[1]
|
|
||||||
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
|
|
||||||
.group_by(ChargeLog.battery_id).all()
|
|
||||||
}
|
|
||||||
active = [b for b in batteries if b.status in ("available", "installed")]
|
active = [b for b in batteries if b.status in ("available", "installed")]
|
||||||
needs_attention = {
|
needs_attention = {
|
||||||
"low_capacity": [
|
"low_capacity": [
|
||||||
@@ -138,14 +126,57 @@ def create_app(config_object="config"):
|
|||||||
if b.battery_percentage is not None and b.battery_percentage < 20
|
if b.battery_percentage is not None and b.battery_percentage < 20
|
||||||
] if ha_client.enabled else [],
|
] if ha_client.enabled else [],
|
||||||
}
|
}
|
||||||
return render_template("dashboard.html", batteries=batteries,
|
total_batteries = len(batteries)
|
||||||
|
avail_count = sum(1 for b in batteries if b.status == "available")
|
||||||
|
installed_count = sum(1 for b in batteries if b.status == "installed")
|
||||||
|
retired_count = sum(1 for b in batteries if b.status == "retired")
|
||||||
|
devices = db.query(Device).filter(Device.parent_id == None).all()
|
||||||
|
total_devices = len(devices)
|
||||||
|
full_devices = sum(
|
||||||
|
1 for d in devices
|
||||||
|
if d.effective_slots() > 0 and d.effective_installed_count() >= d.effective_slots()
|
||||||
|
)
|
||||||
|
partial_devices = sum(
|
||||||
|
1 for d in devices
|
||||||
|
if 0 < d.effective_installed_count() < d.effective_slots()
|
||||||
|
)
|
||||||
|
empty_devices = sum(1 for d in devices if d.effective_installed_count() == 0)
|
||||||
|
return render_template("home.html",
|
||||||
|
total_batteries=total_batteries,
|
||||||
|
avail_count=avail_count,
|
||||||
|
installed_count=installed_count,
|
||||||
|
retired_count=retired_count,
|
||||||
|
total_devices=total_devices,
|
||||||
|
full_devices=full_devices,
|
||||||
|
partial_devices=partial_devices,
|
||||||
|
empty_devices=empty_devices,
|
||||||
|
total_charges=total_charges,
|
||||||
|
charges_last_year=charges_last_year,
|
||||||
|
needs_attention=needs_attention,
|
||||||
|
ha_enabled=ha_client.enabled,
|
||||||
|
today=today)
|
||||||
|
|
||||||
|
@app.route("/battery/")
|
||||||
|
def battery_list():
|
||||||
|
batteries = db.query(Battery).order_by(Battery.label).all()
|
||||||
|
storage_locations = [
|
||||||
|
r[0] for r in db.query(Battery.storage_location)
|
||||||
|
.filter(Battery.storage_location.isnot(None))
|
||||||
|
.distinct().order_by(Battery.storage_location).all()
|
||||||
|
]
|
||||||
|
devices = db.query(Device).order_by(Device.name).all()
|
||||||
|
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
|
||||||
|
today = date.today()
|
||||||
|
last_charged_map = {
|
||||||
|
r[0]: r[1]
|
||||||
|
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
|
||||||
|
.group_by(ChargeLog.battery_id).all()
|
||||||
|
}
|
||||||
|
return render_template("battery_list.html", batteries=batteries,
|
||||||
storage_locations=storage_locations, devices=devices,
|
storage_locations=storage_locations, devices=devices,
|
||||||
devices_with_slots=devices_with_slots,
|
devices_with_slots=devices_with_slots,
|
||||||
ha_enabled=ha_client.enabled,
|
ha_enabled=ha_client.enabled,
|
||||||
total_charges=total_charges,
|
|
||||||
charges_last_year=charges_last_year,
|
|
||||||
last_charged_map=last_charged_map,
|
last_charged_map=last_charged_map,
|
||||||
needs_attention=needs_attention,
|
|
||||||
today=today)
|
today=today)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -201,7 +232,7 @@ def create_app(config_object="config"):
|
|||||||
purchase_date=purchase_date, storage_location=storage_location))
|
purchase_date=purchase_date, storage_location=storage_location))
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
|
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
||||||
storage_locations = [
|
storage_locations = [
|
||||||
@@ -454,7 +485,7 @@ def create_app(config_object="config"):
|
|||||||
battery.device_id = device.id
|
battery.device_id = device.id
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"{battery.label} assigned to {device.name}.", "success")
|
flash(f"{battery.label} assigned to {device.name}.", "success")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
return render_template("assign.html", battery=battery, devices=devices_with_slots)
|
return render_template("assign.html", battery=battery, devices=devices_with_slots)
|
||||||
|
|
||||||
@@ -471,7 +502,7 @@ def create_app(config_object="config"):
|
|||||||
battery.device_id = None
|
battery.device_id = None
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"{battery.label} unassigned and marked available.", "success")
|
flash(f"{battery.label} unassigned and marked available.", "success")
|
||||||
return redirect(_safe_next(url_for("dashboard")))
|
return redirect(_safe_next(url_for("battery_list")))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — retire
|
# Battery — retire
|
||||||
@@ -489,7 +520,7 @@ def create_app(config_object="config"):
|
|||||||
battery.device_id = None
|
battery.device_id = None
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"{battery.label} has been retired.", "success")
|
flash(f"{battery.label} has been retired.", "success")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — unretire
|
# Battery — unretire
|
||||||
@@ -522,7 +553,7 @@ def create_app(config_object="config"):
|
|||||||
db.delete(battery)
|
db.delete(battery)
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Battery {label} permanently deleted.", "success")
|
flash(f"Battery {label} permanently deleted.", "success")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
return render_template("battery_delete.html", battery=battery)
|
return render_template("battery_delete.html", battery=battery)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -534,7 +565,7 @@ def create_app(config_object="config"):
|
|||||||
ids = request.form.getlist("battery_ids", type=int)
|
ids = request.form.getlist("battery_ids", type=int)
|
||||||
if not ids:
|
if not ids:
|
||||||
flash("No batteries selected.", "error")
|
flash("No batteries selected.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
|
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
@@ -563,7 +594,7 @@ def create_app(config_object="config"):
|
|||||||
new_brand = request.form.get("new_brand", "").strip()
|
new_brand = request.form.get("new_brand", "").strip()
|
||||||
if not new_brand:
|
if not new_brand:
|
||||||
flash("Brand name is required.", "error")
|
flash("Brand name is required.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
for b in batteries:
|
for b in batteries:
|
||||||
b.brand = new_brand
|
b.brand = new_brand
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -572,15 +603,15 @@ def create_app(config_object="config"):
|
|||||||
device_id = request.form.get("device_id", type=int)
|
device_id = request.form.get("device_id", type=int)
|
||||||
if not device_id:
|
if not device_id:
|
||||||
flash("Please select a device.", "error")
|
flash("Please select a device.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
device = db.get(Device, device_id)
|
device = db.get(Device, device_id)
|
||||||
if device is None:
|
if device is None:
|
||||||
flash("Device not found.", "error")
|
flash("Device not found.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
if device.has_children():
|
if device.has_children():
|
||||||
flash(f"{device.name} has sub-components; install batteries into those instead.", "error")
|
flash(f"{device.name} has sub-components; install batteries into those instead.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
already_here = [b for b in batteries if b.device_id == device.id]
|
already_here = [b for b in batteries if b.device_id == device.id]
|
||||||
retired_sel = [b for b in batteries if b.is_retired()]
|
retired_sel = [b for b in batteries if b.is_retired()]
|
||||||
@@ -589,7 +620,7 @@ def create_app(config_object="config"):
|
|||||||
|
|
||||||
if not to_process:
|
if not to_process:
|
||||||
flash("No eligible batteries to install.", "error")
|
flash("No eligible batteries to install.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
free_slots = device.battery_slots - device.installed_count()
|
free_slots = device.battery_slots - device.installed_count()
|
||||||
if len(to_process) > free_slots:
|
if len(to_process) > free_slots:
|
||||||
@@ -598,7 +629,7 @@ def create_app(config_object="config"):
|
|||||||
f"but {len(to_process)} need installing.",
|
f"but {len(to_process)} need installing.",
|
||||||
"error",
|
"error",
|
||||||
)
|
)
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
existing_brands = device.installed_brands()
|
existing_brands = device.installed_brands()
|
||||||
new_brands = set(b.brand for b in to_process)
|
new_brands = set(b.brand for b in to_process)
|
||||||
@@ -627,10 +658,10 @@ def create_app(config_object="config"):
|
|||||||
allowed = {"brand", "storage_location"}
|
allowed = {"brand", "storage_location"}
|
||||||
if field_name not in allowed:
|
if field_name not in allowed:
|
||||||
flash("Invalid field.", "error")
|
flash("Invalid field.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
if field_name == "brand" and not field_value:
|
if field_name == "brand" and not field_value:
|
||||||
flash("Brand name is required.", "error")
|
flash("Brand name is required.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
for b in batteries:
|
for b in batteries:
|
||||||
setattr(b, field_name, field_value)
|
setattr(b, field_name, field_value)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -640,7 +671,7 @@ def create_app(config_object="config"):
|
|||||||
date_val = _parse_date(request.form.get("charged_date", "").strip())
|
date_val = _parse_date(request.form.get("charged_date", "").strip())
|
||||||
if not date_val:
|
if not date_val:
|
||||||
flash("A valid date (YYYY-MM-DD) is required.", "error")
|
flash("A valid date (YYYY-MM-DD) is required.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
increment = 1 if request.form.get("increment_cycles") else 0
|
increment = 1 if request.form.get("increment_cycles") else 0
|
||||||
for b in batteries:
|
for b in batteries:
|
||||||
_record_charge(db, b, date_val, increment, notes=None)
|
_record_charge(db, b, date_val, increment, notes=None)
|
||||||
@@ -654,7 +685,7 @@ def create_app(config_object="config"):
|
|||||||
else:
|
else:
|
||||||
flash("Unknown action.", "error")
|
flash("Unknown action.", "error")
|
||||||
|
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("battery_list"))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — list
|
# Devices — list
|
||||||
|
|||||||
+39
-3
@@ -5,9 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Battery Tracker{% endblock %}</title>
|
<title>{% block title %}Battery Tracker{% endblock %}</title>
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#1e40af" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#0c1a3b" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Batteries">
|
<meta name="apple-mobile-web-app-title" content="Batteries">
|
||||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<a class="brand" href="{{ url_for('dashboard') }}">Battery Tracker</a>
|
<a class="brand" href="{{ url_for('home') }}">Battery Tracker</a>
|
||||||
|
<a href="{{ url_for('battery_list') }}">Batteries</a>
|
||||||
<a href="{{ url_for('device_list') }}">Devices</a>
|
<a href="{{ url_for('device_list') }}">Devices</a>
|
||||||
<a href="{{ url_for('battery_add') }}">+ Battery</a>
|
<a href="{{ url_for('battery_add') }}">+ Battery</a>
|
||||||
<a href="{{ url_for('device_add') }}">+ Device</a>
|
<a href="{{ url_for('device_add') }}">+ Device</a>
|
||||||
@@ -56,5 +58,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
|
||||||
|
|
||||||
|
<nav class="bottom-nav" aria-label="Main navigation">
|
||||||
|
{% set ep = request.endpoint or '' %}
|
||||||
|
<a href="{{ url_for('home') }}" class="bottom-nav-item{% if ep == 'home' %} active{% endif %}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('battery_list') }}" class="bottom-nav-item{% if ep == 'battery_list' %} active{% endif %}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2" y="7" width="16" height="10" rx="2"/><path d="M22 11v2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Batteries</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') and ep != 'device_add' %} active{% endif %}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<span>Devices</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('battery_add') }}" class="bottom-nav-item{% if ep == 'battery_add' %} active{% endif %}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="2" y="7" width="16" height="10" rx="2"/><path d="M22 11v2"/><line x1="10" y1="11" x2="10" y2="15"/><line x1="8" y1="13" x2="12" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
<span>+ Battery</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('device_add') }}" class="bottom-nav-item{% if ep == 'device_add' %} active{% endif %}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>+ Device</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" type="submit">Add Batteries</button>
|
<button class="btn btn-primary" type="submit">Add Batteries</button>
|
||||||
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
|
<a class="btn btn-secondary" href="{{ url_for('battery_list') }}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -362,7 +362,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="text-muted" href="{{ url_for('dashboard') }}">← Back to Dashboard</a>
|
<a class="text-muted" href="{{ url_for('battery_list') }}">← Back to Batteries</a>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Home — Battery Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Battery Tracker</h1>
|
||||||
|
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Batteries</h2>
|
||||||
|
<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_batteries }}</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:var(--count-available);">{{ avail_count }}</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:var(--count-installed);">{{ installed_count }}</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:var(--count-retired);">{{ retired_count }}</div>
|
||||||
|
<div class="text-muted">Retired</div>
|
||||||
|
</div>
|
||||||
|
{% if ha_enabled and needs_attention.low_pct %}
|
||||||
|
<a href="#needs-attention"
|
||||||
|
onclick="var d=document.getElementById('needs-attention');d.open=true;"
|
||||||
|
style="flex:1;min-width:120px;text-decoration:none;">
|
||||||
|
<div class="card" style="text-align:center;border:2px solid #f59e0b;cursor:pointer;">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ needs_attention.low_pct|length }}</div>
|
||||||
|
<div class="text-muted">Low Battery</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Devices</h2>
|
||||||
|
<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_devices }}</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:var(--count-installed);">{{ full_devices }}</div>
|
||||||
|
<div class="text-muted">Full</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="flex:1;min-width:120px;text-align:center;">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ partial_devices }}</div>
|
||||||
|
<div class="text-muted">Partial</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="flex:1;min-width:120px;text-align:center;{% if empty_devices > 0 %}border:2px solid #f59e0b;{% endif %}">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;{% if empty_devices > 0 %}color:#f59e0b;{% else %}color:var(--count-retired);{% endif %}">{{ empty_devices }}</div>
|
||||||
|
<div class="text-muted">Empty</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total_charges %}
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||||
|
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;">Charged <strong>{{ total_charges }}</strong>× total</span>
|
||||||
|
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;"><strong>{{ charges_last_year }}</strong>× in last year</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set na_low_cap = needs_attention.low_capacity %}
|
||||||
|
{% set na_low_pct = needs_attention.low_pct %}
|
||||||
|
{% if na_low_cap or na_low_pct %}
|
||||||
|
<details id="needs-attention" class="card" style="margin-bottom:1rem;">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;color:var(--text-warning);list-style:none;display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<span>⚠</span>
|
||||||
|
<span>Needs Attention <span class="badge badge-warning">{{ (na_low_cap|length) + (na_low_pct|length) }}</span></span>
|
||||||
|
</summary>
|
||||||
|
<div style="margin-top:0.75rem;display:flex;flex-wrap:wrap;gap:1.5rem;">
|
||||||
|
{% if na_low_cap %}
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Capacity (<80%)</div>
|
||||||
|
{% for b in na_low_cap %}
|
||||||
|
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
|
||||||
|
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
|
||||||
|
<span class="text-muted">— {{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}% of rated</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if na_low_pct %}
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Battery %</div>
|
||||||
|
{% for b in na_low_pct %}
|
||||||
|
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
|
||||||
|
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
|
||||||
|
<span class="text-muted">— {{ b.battery_percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-top:0.5rem;">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('battery_add') }}">+ Add Battery</a>
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('battery_list') }}">View All Batteries</a>
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('device_list') }}">View All Devices</a>
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('device_add') }}">+ Add Device</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
|
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
<a href="{{ url_for('home') }}" class="btn btn-primary">Go to Home</a>
|
||||||
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
|
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,17 +30,52 @@ def follow(client, resp):
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Dashboard
|
# Home page
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_dashboard_loads(seeded_client):
|
def test_home_loads(seeded_client):
|
||||||
resp = seeded_client.get("/")
|
resp = seeded_client.get("/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
assert b"Available" in resp.data
|
||||||
|
assert b"Installed" in resp.data
|
||||||
|
assert b"Devices" in resp.data
|
||||||
|
assert b"BrandX 001" not in resp.data # no battery table on home page
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_needs_attention_shows_low_capacity(client):
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
client.post("/battery/1/edit-details", data={"capacity_mah": "2000"})
|
||||||
|
client.post("/battery/1/capacity-test/add",
|
||||||
|
data={"tested_capacity_mah": "1500", "tested_date": "2026-01-01"})
|
||||||
|
resp = client.get("/")
|
||||||
|
assert b"Needs Attention" in resp.data
|
||||||
|
assert b"Eneloop 001" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_shows_device_counts(seeded_client):
|
||||||
|
resp = seeded_client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Devices" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Battery list
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_battery_list_loads(seeded_client):
|
||||||
|
resp = seeded_client.get("/battery/")
|
||||||
|
assert resp.status_code == 200
|
||||||
assert b"BrandX 001" in resp.data
|
assert b"BrandX 001" in resp.data
|
||||||
assert b"BrandY 001" in resp.data
|
assert b"BrandY 001" in resp.data
|
||||||
assert b"BrandX 002" in resp.data
|
assert b"BrandX 002" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_list_has_filter_bar(seeded_client):
|
||||||
|
resp = seeded_client.get("/battery/")
|
||||||
|
assert b"filter-status" in resp.data
|
||||||
|
assert b"filter-brand" in resp.data
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery add — label preview data
|
# Battery add — label preview data
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -197,7 +232,7 @@ def test_unassign_next_honors_local_path(seeded_client):
|
|||||||
def test_unassign_next_rejects_external_redirect(seeded_client):
|
def test_unassign_next_rejects_external_redirect(seeded_client):
|
||||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
|
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
|
||||||
assert resp.headers["Location"] == "/"
|
assert resp.headers["Location"] == "/battery/"
|
||||||
|
|
||||||
|
|
||||||
def test_unassign_all_next_rejects_external_redirect(seeded_client):
|
def test_unassign_all_next_rejects_external_redirect(seeded_client):
|
||||||
@@ -741,7 +776,7 @@ def test_import_creates_devices_and_batteries(client):
|
|||||||
resp = _post_import(client, payload)
|
resp = _post_import(client, payload)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Import Results" in resp.data
|
assert b"Import Results" in resp.data
|
||||||
dash = client.get("/")
|
dash = client.get("/battery/")
|
||||||
assert b"Eneloop 001" in dash.data
|
assert b"Eneloop 001" in dash.data
|
||||||
assert b"RC Car" in dash.data
|
assert b"RC Car" in dash.data
|
||||||
|
|
||||||
@@ -1054,10 +1089,10 @@ def test_subcomponent_install_batteries(client):
|
|||||||
assert b"full" in resp.data.lower()
|
assert b"full" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_shows_parent_slash_child(client):
|
def test_battery_list_shows_parent_slash_child(client):
|
||||||
_setup_rc_car(client)
|
_setup_rc_car(client)
|
||||||
client.post("/device/2/install-one", data={"battery_id": "1"})
|
client.post("/device/2/install-one", data={"battery_id": "1"})
|
||||||
resp = client.get("/", follow_redirects=True)
|
resp = client.get("/battery/", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"RC Car Set" in resp.data
|
assert b"RC Car Set" in resp.data
|
||||||
assert b"Remote" in resp.data
|
assert b"Remote" in resp.data
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
|
def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
|
||||||
resp = ha_client_f.get("/")
|
resp = ha_client_f.get("/battery/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"ha-pct" in resp.data
|
assert b"ha-pct" in resp.data
|
||||||
|
|
||||||
@@ -351,7 +351,7 @@ def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
|
|||||||
s.commit()
|
s.commit()
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
resp = ha_client_f.get("/")
|
resp = ha_client_f.get("/battery/")
|
||||||
assert b"85%" in resp.data
|
assert b"85%" in resp.data
|
||||||
# badge-warning should NOT appear for this battery's percentage
|
# badge-warning should NOT appear for this battery's percentage
|
||||||
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
|
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
|
||||||
|
|||||||
Reference in New Issue
Block a user