Add device_type field, mobile-friendly improvements, and device filtering
- Device model: add device_type column (String 50, nullable) - Device add/edit: type select with presets + custom entry - Device detail: show type in info card; new Edit Device form - Device list: Type column + client-side filter bar (type + text search) - Mobile: card-style responsive tables on dashboard and device list, form-grid-2col collapse, larger tap targets, stacked form-actions, column picker viewport fix, filter bar full-width controls - Assign page: larger radio touch targets (min-height 44px) - 3 new acceptance tests for device_type (45 total)
This commit is contained in:
@@ -379,7 +379,8 @@ def create_app(config_object="config"):
|
|||||||
@app.route("/device/")
|
@app.route("/device/")
|
||||||
def device_list():
|
def device_list():
|
||||||
devices = db.query(Device).order_by(Device.name).all()
|
devices = db.query(Device).order_by(Device.name).all()
|
||||||
return render_template("device_list.html", devices=devices)
|
device_types = sorted({d.device_type for d in devices if d.device_type})
|
||||||
|
return render_template("device_list.html", devices=devices, device_types=device_types)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — add
|
# Devices — add
|
||||||
@@ -387,14 +388,19 @@ def create_app(config_object="config"):
|
|||||||
|
|
||||||
@app.route("/device/add", methods=["GET", "POST"])
|
@app.route("/device/add", methods=["GET", "POST"])
|
||||||
def device_add():
|
def device_add():
|
||||||
|
all_devices = db.query(Device).all()
|
||||||
|
device_types = sorted({d.device_type for d in all_devices if d.device_type})
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
slots_raw = request.form.get("battery_slots", "1").strip()
|
slots_raw = request.form.get("battery_slots", "1").strip()
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
|
device_type = request.form.get("device_type", "").strip() or None
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
flash("Device name is required.", "error")
|
flash("Device name is required.", "error")
|
||||||
return render_template("device_add.html"), 400
|
return render_template("device_add.html",
|
||||||
|
device_types=device_types), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slots = int(slots_raw)
|
slots = int(slots_raw)
|
||||||
@@ -403,21 +409,25 @@ def create_app(config_object="config"):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Battery slots must be a positive integer.", "error")
|
flash("Battery slots must be a positive integer.", "error")
|
||||||
return render_template("device_add.html",
|
return render_template("device_add.html",
|
||||||
form_name=name, form_notes=notes or ""), 400
|
device_types=device_types,
|
||||||
|
form_name=name, form_notes=notes or "",
|
||||||
|
form_device_type=request.form.get("device_type", "")), 400
|
||||||
|
|
||||||
if db.query(Device).filter_by(name=name).first():
|
if db.query(Device).filter_by(name=name).first():
|
||||||
flash(f"A device named '{name}' already exists.", "error")
|
flash(f"A device named '{name}' already exists.", "error")
|
||||||
return render_template("device_add.html",
|
return render_template("device_add.html",
|
||||||
|
device_types=device_types,
|
||||||
form_name=name, form_slots=slots,
|
form_name=name, form_slots=slots,
|
||||||
form_notes=notes or ""), 400
|
form_notes=notes or "",
|
||||||
|
form_device_type=request.form.get("device_type", "")), 400
|
||||||
|
|
||||||
device = Device(name=name, battery_slots=slots, notes=notes)
|
device = Device(name=name, battery_slots=slots, notes=notes, device_type=device_type)
|
||||||
db.add(device)
|
db.add(device)
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Device '{name}' added.", "success")
|
flash(f"Device '{name}' added.", "success")
|
||||||
return redirect(url_for("device_list"))
|
return redirect(url_for("device_list"))
|
||||||
|
|
||||||
return render_template("device_add.html")
|
return render_template("device_add.html", device_types=device_types)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — detail
|
# Devices — detail
|
||||||
@@ -432,8 +442,47 @@ def create_app(config_object="config"):
|
|||||||
available_batteries = (db.query(Battery)
|
available_batteries = (db.query(Battery)
|
||||||
.filter_by(status="available")
|
.filter_by(status="available")
|
||||||
.order_by(Battery.label).all())
|
.order_by(Battery.label).all())
|
||||||
|
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
|
||||||
return render_template("device_detail.html", device=device, brands=brands,
|
return render_template("device_detail.html", device=device, brands=brands,
|
||||||
available_batteries=available_batteries)
|
available_batteries=available_batteries,
|
||||||
|
device_types=device_types)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Devices — edit
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@app.route("/device/<int:device_id>/edit", methods=["POST"])
|
||||||
|
def device_edit(device_id):
|
||||||
|
device = db.get(Device, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
slots_raw = request.form.get("battery_slots", "1").strip()
|
||||||
|
notes = request.form.get("notes", "").strip() or None
|
||||||
|
device_type = request.form.get("device_type", "").strip() or None
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash("Device name is required.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
try:
|
||||||
|
slots = int(slots_raw)
|
||||||
|
if slots < 1:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
flash("Battery slots must be a positive integer.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
existing = db.query(Device).filter_by(name=name).first()
|
||||||
|
if existing and existing.id != device_id:
|
||||||
|
flash(f"A device named '{name}' already exists.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
|
device.name = name
|
||||||
|
device.battery_slots = slots
|
||||||
|
device.notes = notes
|
||||||
|
device.device_type = device_type
|
||||||
|
db.commit()
|
||||||
|
flash("Device updated.", "success")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — install batteries
|
# Devices — install batteries
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class Device(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String(100), nullable=False, unique=True)
|
name = Column(String(100), nullable=False, unique=True)
|
||||||
battery_slots = Column(Integer, nullable=False, default=1)
|
battery_slots = Column(Integer, nullable=False, default=1)
|
||||||
|
device_type = Column(String(50), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
batteries = relationship("Battery", back_populates="device")
|
batteries = relationship("Battery", back_populates="device")
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
{% set full = device.installed_count() >= device.battery_slots %}
|
{% set full = device.installed_count() >= device.battery_slots %}
|
||||||
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
|
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
|
||||||
<div style="margin-bottom:0.75rem;padding:0.6rem 0.75rem;border:1px solid #e2e8f0;border-radius:4px;
|
<div style="margin-bottom:0.75rem;padding:0.75rem;border:1px solid #e2e8f0;border-radius:4px;
|
||||||
{% if full %}opacity:0.5;{% endif %}background:#fff;">
|
{% if full %}opacity:0.5;{% endif %}background:#fff;">
|
||||||
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
|
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
|
||||||
<input type="radio" name="device_id" value="{{ device.id }}"
|
<input type="radio" name="device_id" value="{{ device.id }}"
|
||||||
{% if full %}disabled{% endif %}
|
{% if full %}disabled{% endif %}
|
||||||
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
|
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
|
||||||
|
|||||||
@@ -86,6 +86,83 @@
|
|||||||
.text-warning { color: #b45309; font-size: 0.8rem; }
|
.text-warning { color: #b45309; font-size: 0.8rem; }
|
||||||
.text-danger { color: #dc2626; }
|
.text-danger { color: #dc2626; }
|
||||||
.text-muted { color: #6b7280; font-size: 0.85rem; }
|
.text-muted { color: #6b7280; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ─── Mobile responsive ─────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
|
/* Tap targets */
|
||||||
|
.btn { padding: 0.5rem 0.9rem; }
|
||||||
|
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.825rem; }
|
||||||
|
|
||||||
|
/* Collapse 2-col form grids */
|
||||||
|
.form-grid-2col { grid-template-columns: 1fr !important; }
|
||||||
|
|
||||||
|
/* Card-style table rows */
|
||||||
|
.responsive-table thead { display: none; }
|
||||||
|
.responsive-table tr {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.responsive-table tr:hover td { background: transparent; }
|
||||||
|
.responsive-table td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
border-top: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.responsive-table td:last-child { border-bottom: none; }
|
||||||
|
.responsive-table td:empty { display: none; }
|
||||||
|
.responsive-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Checkbox column: no label prefix, left-aligned */
|
||||||
|
.responsive-table td[data-label=""] {
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
/* Actions column: allow wrapping, override nowrap */
|
||||||
|
.responsive-table td[data-label="Actions"] {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
.responsive-table td[data-label="Actions"] select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar: full-width controls with larger tap targets */
|
||||||
|
#filter-bar select,
|
||||||
|
#filter-bar input[type=text] { flex: 1 1 100%; width: 100% !important; padding: 0.5rem 0.65rem !important; font-size: 0.9rem !important; }
|
||||||
|
|
||||||
|
/* Bulk toolbar: prevent text inputs overflowing */
|
||||||
|
#bulk-toolbar input[type=text] { width: 100% !important; }
|
||||||
|
|
||||||
|
/* Install grid on device_detail */
|
||||||
|
#install-grid { grid-template-columns: 1fr !important; max-width: 100% !important; }
|
||||||
|
|
||||||
|
/* Column picker: keep panel inside viewport */
|
||||||
|
#col-picker-panel { left: 0; right: auto !important; max-width: calc(100vw - 2rem); }
|
||||||
|
|
||||||
|
/* form-actions: stack buttons full-width */
|
||||||
|
.form-actions { flex-direction: column; align-items: stretch; }
|
||||||
|
.form-actions .btn,
|
||||||
|
.form-actions button { width: 100%; text-align: center; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<form method="post" action="{{ url_for('battery_add') }}">
|
<form method="post" action="{{ url_for('battery_add') }}">
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
|
<div class="form-grid-2col" style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
|
||||||
|
|
||||||
<div class="form-group" style="grid-column:1/-1;">
|
<div class="form-group" style="grid-column:1/-1;">
|
||||||
<label for="brand-select">Brand <span class="text-danger">*</span></label>
|
<label for="brand-select">Brand <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
<h2>Edit Details</h2>
|
<h2>Edit Details</h2>
|
||||||
<form method="post" action="{{ url_for('battery_edit_details', battery_id=battery.id) }}">
|
<form method="post" action="{{ url_for('battery_edit_details', battery_id=battery.id) }}">
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
|
<div class="form-grid-2col" style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Size</label>
|
<label>Size</label>
|
||||||
|
|||||||
+13
-13
@@ -127,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
||||||
@@ -147,24 +147,24 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for b in batteries %}
|
{% for b in batteries %}
|
||||||
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}">
|
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}">
|
||||||
<td><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
<td data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
||||||
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
||||||
<td>{{ b.brand }}</td>
|
<td data-label="Brand">{{ b.brand }}</td>
|
||||||
<td>{{ b.size or '—' }}</td>
|
<td data-label="Size">{{ b.size or '—' }}</td>
|
||||||
<td class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
|
<td data-label="Chemistry" class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
|
||||||
<td class="col-capacity" style="display:none;">
|
<td data-label="Capacity" class="col-capacity" style="display:none;">
|
||||||
{% if b.capacity_mah %}
|
{% if b.capacity_mah %}
|
||||||
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
|
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
|
||||||
{% else %}{{ b.capacity_mah }} mAh{% endif %}
|
{% else %}{{ b.capacity_mah }} mAh{% endif %}
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
||||||
<td class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
||||||
<td class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
|
<td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
|
||||||
<td>
|
<td data-label="Status">
|
||||||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Assigned To">
|
||||||
{% if b.device %}
|
{% if b.device %}
|
||||||
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
||||||
{% if b.device.has_mixed_brands() %}
|
{% if b.device.has_mixed_brands() %}
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space:nowrap;">
|
<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>
|
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
|
||||||
|
|
||||||
{% if b.is_available() %}
|
{% if b.is_available() %}
|
||||||
|
|||||||
@@ -18,6 +18,27 @@
|
|||||||
value="{{ form_slots|default(1) }}" min="1" required>
|
value="{{ form_slots|default(1) }}" min="1" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
||||||
|
{% set _cur_type = form_device_type|default('') %}
|
||||||
|
<select id="device-type-select" onchange="metaSelectChanged(this,'device_type')">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{% for opt in _preset_types %}
|
||||||
|
<option value="{{ opt }}" {% if _cur_type == opt %}selected{% endif %}>{{ opt }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% for opt in device_types|default([]) %}
|
||||||
|
{% if opt not in _preset_types %}
|
||||||
|
<option value="{{ opt }}" {% if _cur_type == opt %}selected{% endif %}>{{ opt }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<option value="__new__" {% if _cur_type and _cur_type not in _preset_types and _cur_type not in device_types|default([]) %}selected{% endif %}>Other…</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="device_type" name="device_type" value="{{ _cur_type }}"
|
||||||
|
placeholder="Enter device type"
|
||||||
|
style="display:{% if _cur_type and _cur_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
|
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
|
||||||
@@ -29,4 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function metaSelectChanged(sel, inputId) {
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
if (sel.value === '__new__') {
|
||||||
|
input.style.display = '';
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
input.style.display = 'none';
|
||||||
|
input.value = sel.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Slots</td>
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Slots</td>
|
||||||
<td style="border:none;">{{ device.installed_count() }} / {{ device.battery_slots }} used</td>
|
<td style="border:none;">{{ device.installed_count() }} / {{ device.battery_slots }} used</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if device.device_type %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Type</td>
|
||||||
|
<td style="border:none;">{{ device.device_type }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if device.has_mixed_brands() %}
|
{% if device.has_mixed_brands() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
|
||||||
@@ -56,6 +62,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function editTypeSelectChanged(sel) {
|
||||||
|
var input = document.getElementById('edit-device-type');
|
||||||
|
if (sel.value === '__new__') {
|
||||||
|
input.style.display = ''; input.value = ''; input.focus();
|
||||||
|
} else {
|
||||||
|
input.style.display = 'none'; input.value = sel.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
function brandSelectChanged(sel) {
|
function brandSelectChanged(sel) {
|
||||||
var input = sel.nextElementSibling;
|
var input = sel.nextElementSibling;
|
||||||
if (sel.value === '__new__') {
|
if (sel.value === '__new__') {
|
||||||
@@ -83,17 +97,17 @@ function addInstallRow() {
|
|||||||
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
|
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
|
||||||
{% if installed %}
|
{% if installed %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr>
|
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for b in installed %}
|
{% for b in installed %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
|
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
|
||||||
<td>{{ b.brand }}</td>
|
<td data-label="Brand">{{ b.brand }}</td>
|
||||||
<td class="text-muted">{{ b.notes or '—' }}</td>
|
<td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td>
|
||||||
<td>
|
<td data-label="Actions">
|
||||||
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
|
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
|
||||||
<input type="hidden" name="next" value="{{ url_for('device_detail', device_id=device.id) }}">
|
<input type="hidden" name="next" value="{{ url_for('device_detail', device_id=device.id) }}">
|
||||||
<button class="btn btn-sm btn-warning" type="submit">Unassign</button>
|
<button class="btn btn-sm btn-warning" type="submit">Unassign</button>
|
||||||
@@ -131,6 +145,45 @@ function addInstallRow() {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Edit Device</h2>
|
||||||
|
<form method="post" action="{{ url_for('device_edit', device_id=device.id) }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-name">Name</label>
|
||||||
|
<input type="text" id="edit-name" name="name" value="{{ device.name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-slots">Battery Slots</label>
|
||||||
|
<input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
||||||
|
<select id="edit-device-type-select" onchange="editTypeSelectChanged(this)">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{% for opt in _preset_types %}
|
||||||
|
<option value="{{ opt }}" {% if device.device_type == opt %}selected{% endif %}>{{ opt }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% for opt in device_types|default([]) %}
|
||||||
|
{% if opt not in _preset_types %}
|
||||||
|
<option value="{{ opt }}" {% if device.device_type == opt %}selected{% endif %}>{{ opt }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<option value="__new__" {% if device.device_type and device.device_type not in _preset_types and device.device_type not in device_types|default([]) %}selected{% endif %}>Other…</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="edit-device-type" name="device_type"
|
||||||
|
value="{{ device.device_type or '' }}"
|
||||||
|
placeholder="Enter device type"
|
||||||
|
style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-notes">Notes</label>
|
||||||
|
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Delete Device</h2>
|
<h2>Delete Device</h2>
|
||||||
<p style="margin-bottom:1rem;" class="text-muted">
|
<p style="margin-bottom:1rem;" class="text-muted">
|
||||||
|
|||||||
@@ -5,11 +5,27 @@
|
|||||||
<h1>Devices</h1>
|
<h1>Devices</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
|
||||||
|
<select id="filter-type" onchange="applyDeviceFilters()"
|
||||||
|
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for t in device_types|default([]) %}
|
||||||
|
<option value="{{ t }}">{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-device-text" oninput="applyDeviceFilters()" placeholder="Search…"
|
||||||
|
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;">
|
||||||
|
<button type="button" onclick="resetDeviceFilters()" class="btn btn-sm btn-secondary"
|
||||||
|
id="device-filter-reset" style="display:none;">✕ Reset</button>
|
||||||
|
<span id="device-filter-count" style="font-size:0.8rem;color:#64748b;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Slots</th>
|
<th>Slots</th>
|
||||||
<th>Installed</th>
|
<th>Installed</th>
|
||||||
<th>Brands</th>
|
<th>Brands</th>
|
||||||
@@ -18,16 +34,17 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
<tr>
|
<tr data-type="{{ d.device_type or '' }}" data-name="{{ d.name|lower }}">
|
||||||
<td><a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a></td>
|
<td data-label="Device"><a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a></td>
|
||||||
<td>{{ d.battery_slots }}</td>
|
<td data-label="Type">{{ d.device_type or '—' }}</td>
|
||||||
<td>
|
<td data-label="Slots">{{ d.battery_slots }}</td>
|
||||||
|
<td data-label="Installed">
|
||||||
{{ d.installed_count() }} / {{ d.battery_slots }}
|
{{ d.installed_count() }} / {{ d.battery_slots }}
|
||||||
{% if d.installed_count() >= d.battery_slots %}
|
{% if d.installed_count() >= d.battery_slots %}
|
||||||
<span class="badge badge-retired">Full</span>
|
<span class="badge badge-retired">Full</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Brands">
|
||||||
{% set brands = d.installed_brands() %}
|
{% set brands = d.installed_brands() %}
|
||||||
{% if brands %}
|
{% if brands %}
|
||||||
{{ brands|join(', ') }}
|
{{ brands|join(', ') }}
|
||||||
@@ -38,7 +55,7 @@
|
|||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space:nowrap;">
|
<td data-label="Actions" style="white-space:nowrap;">
|
||||||
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a>
|
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a>
|
||||||
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
|
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
|
||||||
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
|
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
|
||||||
@@ -47,7 +64,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
|
<tr><td colspan="6" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -55,4 +72,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
|
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function applyDeviceFilters() {
|
||||||
|
var typeVal = document.getElementById('filter-type').value.toLowerCase();
|
||||||
|
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
|
||||||
|
var rows = document.querySelectorAll('tbody tr[data-name]');
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var rowType = (row.dataset.type || '').toLowerCase();
|
||||||
|
var rowName = (row.dataset.name || '').toLowerCase();
|
||||||
|
var show = (!typeVal || rowType === typeVal) &&
|
||||||
|
(!textVal || rowName.includes(textVal) || rowType.includes(textVal));
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
var active = typeVal || textVal;
|
||||||
|
document.getElementById('device-filter-reset').style.display = active ? '' : 'none';
|
||||||
|
document.getElementById('device-filter-count').textContent =
|
||||||
|
active ? (visible + ' of ' + rows.length + ' shown') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDeviceFilters() {
|
||||||
|
document.getElementById('filter-type').value = '';
|
||||||
|
document.getElementById('filter-device-text').value = '';
|
||||||
|
applyDeviceFilters();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -456,6 +456,30 @@ def test_delete_device_removed(seeded_client):
|
|||||||
# Full round-trip
|
# Full round-trip
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_add_device_with_type(client):
|
||||||
|
resp = client.post("/device/add",
|
||||||
|
data={"name": "TV Remote", "battery_slots": "2", "device_type": "Remote Control"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"TV Remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_detail_shows_type(client):
|
||||||
|
client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"})
|
||||||
|
resp = client.get("/device/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Flashlight" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_device_type(client):
|
||||||
|
client.post("/device/add", data={"name": "Torch", "battery_slots": "1"})
|
||||||
|
resp = client.post("/device/1/edit",
|
||||||
|
data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Flashlight" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_add_install_delete_battery(client):
|
def test_add_install_delete_battery(client):
|
||||||
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
||||||
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
||||||
|
|||||||
Reference in New Issue
Block a user