Files
location_managemet/app/templates/devices/edit.html
2026-03-30 15:49:26 +03:00

474 lines
21 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 %}{% if device %}Edit Device {{ device.name }}{% else %}Add Device{% endif %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('devices.list_devices') }}">Devices</a></li>
<li class="breadcrumb-item active">
{% if device %}Edit {{ device.name }}{% else %}Add Device{% endif %}
</li>
</ol>
</nav>
<h2 class="fw-bold mb-1">
<i class="bi bi-hdd-stack me-2 text-info"></i>
{% if device %}Edit Device{% else %}Add Device{% endif %}
</h2>
<p class="text-secondary mb-4">
Give your device a name and map it to a relay or sensor channel on one of your boards.
Once mapped, toggling the device will control the physical hardware.
</p>
<form method="POST" id="deviceForm">
<!-- Hidden binding fields submitted with the form -->
<input type="hidden" id="hBoardId" name="board_id" value="{{ device.board_id if device and device.board_id else '' }}" />
<input type="hidden" id="hEntityType" name="entity_type" value="{{ device.entity_type if device and device.entity_type else '' }}" />
<input type="hidden" id="hEntityNum" name="entity_num" value="{{ device.entity_num if device and device.entity_num else '' }}" />
<div class="row g-4">
<!-- ══════════════════ LEFT: identity + icon + type ══════════════════════ -->
<div class="col-lg-4">
<!-- Identity -->
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-tag me-1 text-info"></i> Identity</h5>
<div class="mb-3">
<label class="form-label" for="name">Device name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ device.name if device else '' }}"
placeholder="e.g. Outdoor Light 1 Courtyard"
maxlength="128" required />
</div>
<div class="mb-3">
<label class="form-label" for="description">Description <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" id="description" name="description"
value="{{ device.description if device and device.description else '' }}"
placeholder="Short description" maxlength="256" />
</div>
<div class="mb-0">
<label class="form-label" for="area">Area / Location <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" id="area" name="area"
value="{{ device.area if device and device.area else '' }}"
placeholder="e.g. Courtyard, Living Room, Garage"
maxlength="64" />
</div>
</div>
</div>
<!-- Device type -->
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-grid-3x3-gap me-1 text-success"></i> Device Type</h5>
<p class="text-secondary small mb-3">Sets default icon, state labels and colours. Auto-filled when you pick a channel.</p>
<div id="relay-types-wrap">
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in relay_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}" data-icon="{{ tdef.icon }}">
<input type="radio" name="device_class" value="{{ tkey }}" hidden
{% if (device and device.device_class == tkey) or (not device and tkey == 'switch') %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
<div id="input-types-wrap" style="display:none">
<div class="d-flex flex-wrap gap-2">
{% for tkey, tdef in input_types.items() %}
<label class="entity-type-pill" title="{{ tdef.label }}" data-icon="{{ tdef.icon }}">
<input type="radio" name="device_class" value="{{ tkey }}" hidden
{% if device and device.device_class == tkey and device.entity_type == 'input' %}checked{% endif %} />
<i class="bi {{ tdef.icon }}"></i>
<span>{{ tdef.label }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Custom icon -->
<div class="card border-0 rounded-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-palette me-1 text-warning"></i> Custom Icon</h5>
<div class="d-flex align-items-center gap-3 mb-3">
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="width:60px;height:60px;background:var(--bs-secondary-bg)">
<i id="icon-preview" class="bi {{ (device.icon or device.effective_icon) if device else 'bi-toggles' }}"
style="font-size:2rem"></i>
</div>
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="iconInput" name="icon"
value="{{ device.icon if device and device.icon else '' }}"
placeholder="bi-lightbulb-fill" />
<button type="button" class="btn btn-outline-secondary" id="iconClearBtn"><i class="bi bi-x"></i></button>
</div>
<div class="form-text">Leave blank to use device type default.</div>
</div>
</div>
<div class="d-flex flex-wrap gap-1" id="iconPalette">
{% for icon_cls, icon_name in icon_palette %}
<button type="button" class="btn btn-sm btn-outline-secondary p-1"
title="{{ icon_name }}" data-icon="{{ icon_cls }}">
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
</button>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- ══════════════════ RIGHT: board + entity card picker ═════════════════ -->
<div class="col-lg-8">
<div class="card border-0 rounded-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-1">
<i class="bi bi-motherboard me-1 text-primary"></i> Board &amp; Channel Binding
</h5>
<p class="text-secondary small mb-4">
Select a board, then click the relay or sensor you want to bind to this device.
Toggling the device later will directly control the physical hardware.
</p>
<!-- Board selector -->
<div class="mb-4">
<label class="form-label fw-semibold" for="boardSelect">Board</label>
<select class="form-select" id="boardSelect">
<option value="">— None (virtual / unlinked device) —</option>
{% for board in boards %}
<option value="{{ board.id }}" data-name="{{ board.name }}"
{% if device and device.board_id == board.id %}selected{% endif %}>
{{ board.name }} · {{ board.board_type }}
</option>
{% endfor %}
</select>
</div>
<!-- Entity panel -->
<div id="entityPanel">
<div id="entityLoading" class="text-center py-4 text-secondary" style="display:none">
<div class="spinner-border spinner-border-sm me-2"></div>Loading channels…
</div>
<div id="entityPlaceholder" class="text-center py-5 text-secondary">
<i class="bi bi-arrow-up-circle display-5 d-block mb-2 opacity-25"></i>
Select a board above to see its relay and sensor channels.
</div>
<!-- Relays -->
<div id="relaySection" style="display:none">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-lightning-charge text-warning me-2"></i>
<span class="fw-semibold">Relay Outputs</span>
<span class="badge text-bg-secondary ms-2" id="relayCount"></span>
<span class="text-secondary small ms-2"> controllable (ON / OFF)</span>
</div>
<div class="row g-2 mb-4" id="relayGrid"></div>
</div>
<!-- Inputs -->
<div id="inputSection" style="display:none">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-arrow-down-circle text-success me-2"></i>
<span class="fw-semibold">Digital Inputs / Sensors</span>
<span class="badge text-bg-secondary ms-2" id="inputCount"></span>
<span class="text-secondary small ms-2"> read-only</span>
</div>
<div class="row g-2" id="inputGrid"></div>
</div>
<!-- Selection summary -->
<div id="bindSummary" class="alert alert-info d-flex align-items-center gap-3 mt-3" style="display:none">
<i class="bi bi-check-circle-fill fs-5"></i>
<div><strong>Bound to:</strong> <span id="bindSummaryText"></span></div>
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="unbindBtn">
<i class="bi bi-x me-1"></i>Remove binding
</button>
</div>
</div>
</div>
</div>
</div>
</div><!-- /row -->
<!-- ── Submit ────────────────────────────────────────────────────────────── -->
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>
{% if device %}Save Changes{% else %}Create Device{% endif %}
</button>
<a href="{{ url_for('devices.list_devices') }}" class="btn btn-outline-secondary">
<i class="bi bi-x me-1"></i>Cancel
</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function () {
const INIT = {
boardId: {{ (device.board_id | tojson) if device and device.board_id else 'null' }},
entityType: {{ (device.entity_type | tojson) if device and device.entity_type else 'null' }},
entityNum: {{ (device.entity_num | tojson) if device and device.entity_num else 'null' }},
devClass: {{ (device.device_class | tojson) if device else '"switch"' }},
};
// DOM refs
const boardSel = document.getElementById("boardSelect");
const hBoardId = document.getElementById("hBoardId");
const hEntityType = document.getElementById("hEntityType");
const hEntityNum = document.getElementById("hEntityNum");
const entityLoading = document.getElementById("entityLoading");
const entityPh = document.getElementById("entityPlaceholder");
const relaySection = document.getElementById("relaySection");
const inputSection = document.getElementById("inputSection");
const relayGrid = document.getElementById("relayGrid");
const inputGrid = document.getElementById("inputGrid");
const relayCount = document.getElementById("relayCount");
const inputCount = document.getElementById("inputCount");
const bindSummary = document.getElementById("bindSummary");
const bindSummaryTxt = document.getElementById("bindSummaryText");
const unbindBtn = document.getElementById("unbindBtn");
const iconInput = document.getElementById("iconInput");
const iconPreview = document.getElementById("icon-preview");
const iconClearBtn = document.getElementById("iconClearBtn");
const relayTypesWrap = document.getElementById("relay-types-wrap");
const inputTypesWrap = document.getElementById("input-types-wrap");
// ── icon preview ─────────────────────────────────────────────────────────
function updateIconPreview() {
const val = iconInput.value.trim();
if (val) { iconPreview.className = "bi " + val; return; }
const checked = document.querySelector(
"#relay-types-wrap input[name=device_class]:checked, " +
"#input-types-wrap input[name=device_class]:checked"
);
if (checked) {
const lbl = checked.closest("label");
if (lbl) iconPreview.className = "bi " + lbl.dataset.icon;
}
}
iconInput.addEventListener("input", updateIconPreview);
iconClearBtn.addEventListener("click", () => { iconInput.value = ""; updateIconPreview(); });
document.getElementById("iconPalette").addEventListener("click", e => {
const btn = e.target.closest("[data-icon]");
if (!btn) return;
iconInput.value = btn.dataset.icon;
updateIconPreview();
});
document.querySelectorAll("label.entity-type-pill input").forEach(r =>
r.addEventListener("change", updateIconPreview)
);
// ── device type tab switch ────────────────────────────────────────────────
function showRelayTypes(preselect) {
relayTypesWrap.style.display = "";
inputTypesWrap.style.display = "none";
inputTypesWrap.querySelectorAll("input").forEach(r => r.checked = false);
if (preselect) {
const m = relayTypesWrap.querySelector(`input[value="${preselect}"]`);
if (m) m.checked = true;
}
if (!relayTypesWrap.querySelector("input:checked")) {
const f = relayTypesWrap.querySelector("input"); if (f) f.checked = true;
}
updateIconPreview();
}
function showInputTypes(preselect) {
inputTypesWrap.style.display = "";
relayTypesWrap.style.display = "none";
relayTypesWrap.querySelectorAll("input").forEach(r => r.checked = false);
if (preselect) {
const m = inputTypesWrap.querySelector(`input[value="${preselect}"]`);
if (m) m.checked = true;
}
if (!inputTypesWrap.querySelector("input:checked")) {
const f = inputTypesWrap.querySelector("input"); if (f) f.checked = true;
}
updateIconPreview();
}
// ── entity card factory ──────────────────────────────────────────────────
function makeCard(item, kind) {
const isSelected = hEntityType.value === kind && parseInt(hEntityNum.value) === item.num;
const stateColor = kind === "relay"
? (item.state ? item.on_color : item.off_color)
: (item.active ? item.active_color : item.idle_color);
const label = kind === "relay" ? "Relay" : "Input";
const col = document.createElement("div");
col.className = "col-6 col-md-4 col-xl-3";
col.innerHTML = `
<div class="card entity-pick-card border-2 rounded-3 h-100
${isSelected ? "border-primary bg-primary bg-opacity-10" : "border-transparent"}"
style="cursor:pointer;transition:all .15s"
data-kind="${kind}" data-num="${item.num}">
<div class="card-body p-3 text-center">
<div class="rounded-3 d-flex align-items-center justify-content-center mx-auto mb-2"
style="width:48px;height:48px;background:var(--bs-secondary-bg)">
<i class="bi ${item.icon} entity-card-icon"
style="font-size:1.5rem;${isSelected ? "color:var(--bs-primary)" : ""}"></i>
</div>
<div class="fw-semibold small text-truncate" title="${item.name}">${item.name}</div>
<div class="text-secondary" style="font-size:.7rem">${label} ${item.num}</div>
<span class="badge text-bg-${stateColor} mt-1" style="font-size:.65rem">${item.state_label}</span>
${isSelected ? '<div class="mt-1"><i class="bi bi-check-circle-fill text-primary"></i></div>' : ''}
</div>
</div>`;
col.querySelector(".entity-pick-card").addEventListener("click", () => selectEntity(item, kind));
return col;
}
// ── select entity ─────────────────────────────────────────────────────────
function selectEntity(item, kind) {
hEntityType.value = kind;
hEntityNum.value = item.num;
document.querySelectorAll(".entity-pick-card").forEach(c => {
const mine = c.dataset.kind === kind && parseInt(c.dataset.num) === item.num;
c.classList.toggle("border-primary", mine);
c.classList.toggle("bg-primary", mine);
c.classList.toggle("bg-opacity-10", mine);
c.classList.toggle("border-transparent", !mine);
c.classList.remove("bg-secondary");
const ico = c.querySelector(".entity-card-icon");
if (ico) ico.style.color = mine ? "var(--bs-primary)" : "";
// show/hide checkmark
let ck = c.querySelector(".entity-check");
if (!ck && mine) {
ck = document.createElement("div");
ck.className = "entity-check mt-1";
ck.innerHTML = '<i class="bi bi-check-circle-fill text-primary"></i>';
c.querySelector(".card-body").appendChild(ck);
} else if (ck && !mine) {
ck.remove();
}
});
kind === "relay" ? showRelayTypes(item.type) : showInputTypes(item.type);
// Auto-fill name if blank
const nameIn = document.getElementById("name");
if (!nameIn.value.trim()) nameIn.value = item.name;
// Summary banner
const boardName = boardSel.options[boardSel.selectedIndex]?.dataset.name || "Board";
bindSummaryTxt.textContent = `${boardName} · ${kind === "relay" ? "Relay" : "Input"} ${item.num}${item.name}`;
bindSummary.style.display = "flex";
}
unbindBtn.addEventListener("click", () => {
hEntityType.value = "";
hEntityNum.value = "";
document.querySelectorAll(".entity-pick-card").forEach(c => {
c.classList.remove("border-primary", "bg-primary", "bg-opacity-10");
c.classList.add("border-transparent");
const ico = c.querySelector(".entity-card-icon");
if (ico) ico.style.color = "";
c.querySelector(".entity-check")?.remove();
});
bindSummary.style.display = "none";
});
// ── load board ────────────────────────────────────────────────────────────
function loadBoard(boardId) {
entityPh.style.display = "none";
relaySection.style.display = "none";
inputSection.style.display = "none";
bindSummary.style.display = "none";
relayGrid.innerHTML = "";
inputGrid.innerHTML = "";
if (!boardId) {
hBoardId.value = "";
entityPh.style.display = "";
return;
}
hBoardId.value = boardId;
entityLoading.style.display = "";
fetch(`/devices/api/boards/${boardId}/entities`)
.then(r => r.json())
.then(data => {
entityLoading.style.display = "none";
if (data.relays && data.relays.length) {
relayCount.textContent = data.relays.length;
data.relays.forEach(item => relayGrid.appendChild(makeCard(item, "relay")));
relaySection.style.display = "";
}
if (data.inputs && data.inputs.length) {
inputCount.textContent = data.inputs.length;
data.inputs.forEach(item => inputGrid.appendChild(makeCard(item, "input")));
inputSection.style.display = "";
}
if (!data.relays.length && !data.inputs.length) {
entityPh.innerHTML = `<i class="bi bi-exclamation-circle display-5 d-block mb-2 opacity-25"></i>
This board has no relay or input channels configured.`;
entityPh.style.display = "";
}
// Restore selection when editing
if (INIT.entityType && INIT.entityNum) {
const all = INIT.entityType === "relay" ? data.relays : data.inputs;
const match = all.find(i => i.num === INIT.entityNum);
if (match) selectEntity(match, INIT.entityType);
}
})
.catch(() => {
entityLoading.style.display = "none";
entityPh.innerHTML = `<i class="bi bi-exclamation-triangle text-warning me-2"></i>Failed to load board channels.`;
entityPh.style.display = "";
});
}
boardSel.addEventListener("change", () => {
hEntityType.value = "";
hEntityNum.value = "";
loadBoard(boardSel.value || null);
});
// ── init ─────────────────────────────────────────────────────────────────
if (INIT.entityType === "input") {
showInputTypes(INIT.devClass);
} else {
showRelayTypes(INIT.devClass);
}
updateIconPreview();
if (INIT.boardId) {
loadBoard(INIT.boardId);
} else {
entityPh.style.display = "";
}
})();
</script>
<style>
.entity-pick-card:hover { background: var(--bs-secondary-bg) !important; transform: translateY(-2px); }
.border-transparent { border-color: transparent !important; }
</style>
{% endblock %}