Files
location_managemet/app/templates/devices/list.html

231 lines
9.5 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Devices Location Management{% endblock %}
{% block content %}
{# ── Collect grouped data ─────────────────────────────────────────────────── #}
{% set named_areas = [] %}
{% for d in devices %}
{% if d.area and d.area != '' and d.area not in named_areas %}
{% set _ = named_areas.append(d.area) %}
{% endif %}
{% endfor %}
{% set bound_count = devices | selectattr('board') | list | length %}
{% set unbound_count = devices | length - bound_count %}
{# ── Page header ──────────────────────────────────────────────────────────── #}
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h2 class="fw-bold mb-0">
<i class="bi bi-hdd-stack me-2 text-info"></i>Devices
</h2>
<p class="text-secondary small mb-0 mt-1">
Virtual devices are independent app entities — bind them to hardware when needed.
</p>
</div>
{% if current_user.is_admin() %}
<a href="{{ url_for('devices.add_device') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add Device
</a>
{% endif %}
</div>
{# ── Stats bar ─────────────────────────────────────────────────────────────── #}
{% if devices %}
<div class="row g-2 mb-4">
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0">{{ devices | length }}</div>
<div class="text-secondary" style="font-size:.72rem">Total</div>
</div>
</div>
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0 text-success">{{ bound_count }}</div>
<div class="text-secondary" style="font-size:.72rem">Bound</div>
</div>
</div>
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0 text-warning">{{ unbound_count }}</div>
<div class="text-secondary" style="font-size:.72rem">Unbound</div>
</div>
</div>
</div>
{# ── Search ────────────────────────────────────────────────────────────────── #}
<div class="mb-4">
<div class="input-group input-group-sm" style="max-width:340px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="deviceSearch"
placeholder="Filter by name, area or app id…">
<button class="btn btn-outline-secondary" type="button" id="deviceSearchClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
{# ── Area groups ──────────────────────────────────────────────────────────── #}
{% for area in named_areas %}
<h5 class="text-secondary fw-semibold mb-3 mt-3 area-heading">
<i class="bi bi-geo-alt me-1"></i>{{ area }}
</h5>
<div class="row g-3 mb-2">
{% for device in devices %}
{% if device.area == area %}
<div class="col-md-6 col-xl-4 device-row" id="device-card-{{ device.id }}"
data-name="{{ device.name | lower }}"
data-area="{{ (device.area or '') | lower }}"
data-appid="{{ (device.app_id or '') | lower }}">
{% include "devices/_card.html" %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{# ── Unbound / no-area devices ─────────────────────────────────────────────── #}
{% set ungrouped = [] %}
{% for d in devices %}
{% if not d.area or d.area == '' %}
{% set _ = ungrouped.append(d) %}
{% endif %}
{% endfor %}
{% if ungrouped %}
{% if named_areas %}
<h5 class="text-secondary fw-semibold mb-3 mt-3 area-heading">
<i class="bi bi-three-dots me-1"></i>Other
</h5>
{% endif %}
<div class="row g-3 mb-2">
{% for device in ungrouped %}
<div class="col-md-6 col-xl-4 device-row" id="device-card-{{ device.id }}"
data-name="{{ device.name | lower }}"
data-area=""
data-appid="{{ (device.app_id or '') | lower }}">
{% include "devices/_card.html" %}
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-hdd-stack display-4 d-block mb-3 opacity-25"></i>
<p class="mb-1">No devices defined yet.</p>
{% if current_user.is_admin() %}
<a href="{{ url_for('devices.add_device') }}" class="btn btn-sm btn-outline-primary mt-2">
<i class="bi bi-plus-circle me-1"></i> Add your first device
</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// ── toggle relay / Tuya / Sonoff ──────────────────────────────────────────────
function deviceToggle(btn, deviceId) {
btn.disabled = true;
fetch(`/devices/${deviceId}/toggle`, {
method: "POST",
headers: {"X-Requested-With": "XMLHttpRequest"}
})
.then(r => r.json())
.then(data => {
if (!data.ok) {
btn.disabled = false;
showToast(data.error || "Toggle failed.", "danger");
return;
}
const card = document.getElementById("device-card-" + deviceId);
const badge = card.querySelector(".device-state-badge");
if (badge) {
badge.className = "badge device-state-badge text-bg-" + data.state_color;
badge.textContent = data.state_label;
}
btn.className = btn.className.replace(/btn-(outline-)?[a-z]+/, "btn-" + data.state_color);
btn.innerHTML = data.state
? '<i class="bi bi-power me-1"></i>ON'
: '<i class="bi bi-power me-1"></i>OFF';
// Update left border colour
const cardEl = card.querySelector(".card");
if (cardEl) {
cardEl.classList.remove("border-success", "border-secondary", "border-warning",
"border-danger", "border-info", "border-primary");
cardEl.classList.add("border-" + data.state_color);
}
btn.disabled = false;
})
.catch(() => { btn.disabled = false; showToast("Network error.", "danger"); });
}
function showToast(msg, type) {
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container position-fixed bottom-0 end-0 p-3";
container.style.zIndex = 1100;
document.body.appendChild(container);
}
const id = "t" + Date.now();
container.insertAdjacentHTML("beforeend",
`<div id="${id}" class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
const el = document.getElementById(id);
const t = new bootstrap.Toast(el, {delay: 4000});
t.show();
el.addEventListener("hidden.bs.toast", () => el.remove());
}
// ── app_id copy ───────────────────────────────────────────────────────────────
document.addEventListener("click", e => {
const btn = e.target.closest(".copy-app-id");
if (!btn) return;
const txt = btn.dataset.appid;
navigator.clipboard.writeText(txt).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(() => { btn.innerHTML = orig; }, 1200);
});
});
// ── search / filter ────────────────────────────────────────────────────────────
const searchInput = document.getElementById("deviceSearch");
const searchClear = document.getElementById("deviceSearchClear");
if (searchInput) {
function applyFilter() {
const q = searchInput.value.toLowerCase().trim();
let anyVisible = {};
document.querySelectorAll(".device-row").forEach(row => {
const match = !q
|| row.dataset.name.includes(q)
|| row.dataset.area.includes(q)
|| row.dataset.appid.includes(q);
row.style.display = match ? "" : "none";
const heading = row.closest(".row")?.previousElementSibling;
if (heading && heading.classList.contains("area-heading")) {
anyVisible[heading.dataset.id || heading.textContent] = (anyVisible[heading.dataset.id || heading.textContent] || false) || match;
}
});
// Hide area headings whose entire group is filtered out
document.querySelectorAll(".area-heading").forEach(h => {
const nextRow = h.nextElementSibling;
if (!nextRow) return;
const visCount = nextRow.querySelectorAll(".device-row:not([style*='none'])").length;
h.style.display = visCount ? "" : "none";
});
}
searchInput.addEventListener("input", applyFilter);
searchClear.addEventListener("click", () => { searchInput.value = ""; applyFilter(); });
}
</script>
{% endblock %}