Improve soundbox ops dashboard and registry editing
This commit is contained in:
@ -278,7 +278,7 @@ Register
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200">
|
||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Device ID</th>
|
||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Serial Number</th>
|
||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Model</th>
|
||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Merchant Binding</th>
|
||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Connection</th>
|
||||
@ -440,6 +440,73 @@ Rows
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit Device Modal -->
|
||||
<div id="device-edit-modal" class="fixed inset-0 z-[70] hidden">
|
||||
<div id="device-edit-overlay" class="absolute inset-0 bg-slate-900/45 backdrop-blur-sm"></div>
|
||||
<div class="relative ml-auto flex h-full w-full max-w-[640px] flex-col bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<h3 class="text-headline-md font-bold text-on-surface">Edit Device</h3>
|
||||
<p class="mt-1 text-body-md text-on-surface-variant">Correct serial number, model, and operational metadata.</p>
|
||||
</div>
|
||||
<button id="device-edit-close" class="flex h-10 w-10 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="device-edit-form" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex-1 space-y-5 overflow-y-auto px-6 py-6">
|
||||
<input id="edit-device-id" type="hidden"/>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
|
||||
Changing SN affects config pull lookup and MQTT topic routing. Use the physical dev-sn printed on the device.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Device Code</span>
|
||||
<input id="edit-device-code" class="w-full rounded-lg border-slate-200 bg-slate-50 text-body-md text-slate-500" disabled type="text"/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Serial Number / dev-sn</span>
|
||||
<input id="edit-serial-number" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary" required type="text"/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Vendor</span>
|
||||
<select id="edit-vendor" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary"></select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Model</span>
|
||||
<select id="edit-model" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary"></select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Communication</span>
|
||||
<select id="edit-communication-mode" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary">
|
||||
<option value="static">Static</option>
|
||||
<option value="mqtt">MQTT</option>
|
||||
<option value="api">API</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Status</span>
|
||||
<select id="edit-status" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-label-md font-bold uppercase text-slate-500">Firmware Version</span>
|
||||
<input id="edit-firmware-version" class="w-full rounded-lg border-slate-200 text-body-md focus:border-primary focus:ring-primary" placeholder="Optional" type="text"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4">
|
||||
<p id="edit-form-status" class="min-h-5 text-body-md text-slate-500"></p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="device-edit-cancel" type="button" class="rounded-lg border border-slate-200 px-4 py-2.5 font-bold text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button id="device-edit-submit" type="submit" class="rounded-lg bg-primary px-5 py-2.5 font-bold text-white hover:bg-primary-container">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Side Inspection Drawer (Initially hidden, triggered by row interaction) -->
|
||||
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] opacity-0 pointer-events-none transition-opacity duration-300" id="device-detail-overlay"></div>
|
||||
<div class="fixed inset-y-0 right-0 w-[450px] bg-white shadow-2xl z-[60] transform translate-x-full transition-transform duration-300 ease-in-out border-l border-slate-200" id="device-detail-drawer">
|
||||
@ -517,6 +584,16 @@ Rows
|
||||
const registerCapabilityPreview = document.getElementById("register-capability-preview");
|
||||
const registerVendor = document.getElementById("register-vendor");
|
||||
const registerModel = document.getElementById("register-model");
|
||||
const editModal = document.getElementById("device-edit-modal");
|
||||
const editForm = document.getElementById("device-edit-form");
|
||||
const editCloseButton = document.getElementById("device-edit-close");
|
||||
const editCancelButton = document.getElementById("device-edit-cancel");
|
||||
const editOverlay = document.getElementById("device-edit-overlay");
|
||||
const editSubmitButton = document.getElementById("device-edit-submit");
|
||||
const editStatus = document.getElementById("edit-form-status");
|
||||
const editVendor = document.getElementById("edit-vendor");
|
||||
const editModel = document.getElementById("edit-model");
|
||||
let activeEditDevice = null;
|
||||
let deviceCatalog = [
|
||||
{
|
||||
vendor: "QF",
|
||||
@ -702,6 +779,36 @@ Rows
|
||||
});
|
||||
};
|
||||
|
||||
const getCatalogModel = (vendor, model) => {
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
||||
return (catalog?.models || []).find((item) => item.model === model) || (catalog?.models || [])[0];
|
||||
};
|
||||
|
||||
const buildCapabilityProfileForModel = (selectedModel, communicationMode, fallbackProfile = {}) => {
|
||||
const template = selectedModel?.capability_template_json || {};
|
||||
const dynamic = Boolean(
|
||||
selectedModel?.screen_flag ||
|
||||
String(selectedModel?.qr_mode || template.qr_mode || fallbackProfile.qr_mode || "").startsWith("dynamic") ||
|
||||
fallbackProfile.device_type === "dynamic_screen_soundbox"
|
||||
);
|
||||
const payloadProfile = selectedModel?.mqtt_payload_profile || template.mqtt_payload_profile || fallbackProfile.mqtt_payload_profile || "";
|
||||
return {
|
||||
...fallbackProfile,
|
||||
...template,
|
||||
device_type: dynamic ? "dynamic_screen_soundbox" : "static_soundbox",
|
||||
screen: dynamic,
|
||||
qr_mode: dynamic ? "dynamic_mqtt" : "static",
|
||||
...(payloadProfile ? { mqtt_payload_profile: payloadProfile } : {}),
|
||||
flows: dynamic ? ["static_payment_notification", "dynamic_qr:mqtt"] : ["static_payment_notification"],
|
||||
features: {
|
||||
...(typeof fallbackProfile.features === "object" && fallbackProfile.features ? fallbackProfile.features : {}),
|
||||
...(typeof template.features === "object" && template.features ? template.features : {}),
|
||||
payment_sound: true,
|
||||
dynamic_qr: dynamic ? { mqtt: communicationMode === "mqtt", display: "screen" } : false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const buildCapabilityProfile = (type, communicationMode) => {
|
||||
const dynamic = type === "dynamic_screen_soundbox";
|
||||
const selectedModel = getSelectedCatalogModel();
|
||||
@ -725,8 +832,7 @@ Rows
|
||||
const getSelectedCatalogModel = () => {
|
||||
const vendor = registerVendor?.value;
|
||||
const model = registerModel?.value;
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
||||
return (catalog?.models || []).find((item) => item.model === model) || (catalog?.models || [])[0];
|
||||
return getCatalogModel(vendor, model);
|
||||
};
|
||||
|
||||
const updateCapabilityPreview = () => {
|
||||
@ -796,26 +902,29 @@ Rows
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateDeviceCatalog = () => {
|
||||
if (!registerVendor || !registerModel) {
|
||||
const hydrateCatalogVendorSelect = (vendorSelect, modelSelect, selectedVendor = "") => {
|
||||
if (!vendorSelect || !modelSelect) {
|
||||
return;
|
||||
}
|
||||
registerVendor.innerHTML = "";
|
||||
vendorSelect.innerHTML = "";
|
||||
deviceCatalog.forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.vendor;
|
||||
option.textContent = item.label || item.vendor;
|
||||
registerVendor.appendChild(option);
|
||||
vendorSelect.appendChild(option);
|
||||
});
|
||||
hydrateDeviceModels(registerVendor.value || deviceCatalog[0]?.vendor || "QF");
|
||||
vendorSelect.value = selectedVendor && deviceCatalog.some((item) => item.vendor === selectedVendor)
|
||||
? selectedVendor
|
||||
: deviceCatalog[0]?.vendor || "QF";
|
||||
hydrateCatalogModelSelect(vendorSelect.value, modelSelect);
|
||||
};
|
||||
|
||||
const hydrateDeviceModels = (vendor) => {
|
||||
if (!registerModel) {
|
||||
const hydrateCatalogModelSelect = (vendor, modelSelect, selectedModel = "") => {
|
||||
if (!modelSelect) {
|
||||
return;
|
||||
}
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
||||
registerModel.innerHTML = "";
|
||||
modelSelect.innerHTML = "";
|
||||
(catalog?.models || []).forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.model;
|
||||
@ -826,8 +935,26 @@ Rows
|
||||
if (item.mqtt_payload_profile) {
|
||||
option.dataset.payloadProfile = item.mqtt_payload_profile;
|
||||
}
|
||||
registerModel.appendChild(option);
|
||||
modelSelect.appendChild(option);
|
||||
});
|
||||
if (selectedModel && (catalog?.models || []).some((item) => item.model === selectedModel)) {
|
||||
modelSelect.value = selectedModel;
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateDeviceCatalog = () => {
|
||||
hydrateCatalogVendorSelect(registerVendor, registerModel);
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === registerVendor?.value) || deviceCatalog[0];
|
||||
const selectedModel = (catalog?.models || [])[0];
|
||||
if (selectedModel?.communication_mode) {
|
||||
document.getElementById("register-communication-mode").value = selectedModel.communication_mode;
|
||||
}
|
||||
updateCapabilityPreview();
|
||||
};
|
||||
|
||||
const hydrateDeviceModels = (vendor) => {
|
||||
hydrateCatalogModelSelect(vendor, registerModel);
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
||||
const selectedModel = (catalog?.models || [])[0];
|
||||
if (selectedModel?.communication_mode) {
|
||||
document.getElementById("register-communication-mode").value = selectedModel.communication_mode;
|
||||
@ -875,8 +1002,8 @@ Rows
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-row-height">
|
||||
<div class="space-y-1">
|
||||
<span class="block font-mono text-primary font-bold">${id || "-"}</span>
|
||||
<span class="block font-mono text-[12px] text-slate-500">SN: ${serialNumber}</span>
|
||||
<span class="block font-mono text-primary font-bold">${serialNumber}</span>
|
||||
<span class="block font-mono text-[12px] text-slate-500">${id ? `Code: ${escapeHtml(id)}` : "Code: -"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-row-height">${model}</td>
|
||||
@ -920,6 +1047,10 @@ Rows
|
||||
<span class="material-symbols-outlined text-[18px]">visibility</span>
|
||||
Quick Inspect
|
||||
</button>
|
||||
<button class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-bold text-slate-700 hover:bg-slate-50" data-action="edit-device" data-device-id="${device.id}">
|
||||
<span class="material-symbols-outlined text-[18px]">edit_square</span>
|
||||
Edit Device
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -948,6 +1079,18 @@ Rows
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tableBody.querySelectorAll("button[data-action='edit-device']").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
tableBody.querySelectorAll("[data-device-menu]").forEach((menu) => menu.classList.add("hidden"));
|
||||
const deviceId = event.currentTarget.getAttribute("data-device-id");
|
||||
const item = rows.find((row) => row.id === deviceId);
|
||||
if (item) {
|
||||
openEditModal(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
@ -1370,6 +1513,98 @@ Rows
|
||||
registerModal?.classList.add("hidden");
|
||||
};
|
||||
|
||||
const openEditModal = (device) => {
|
||||
if (!editModal || !editForm) {
|
||||
return;
|
||||
}
|
||||
activeEditDevice = device;
|
||||
editForm.reset();
|
||||
hydrateCatalogVendorSelect(editVendor, editModel, device.vendor || deviceCatalog[0]?.vendor || "QF");
|
||||
hydrateCatalogModelSelect(editVendor?.value, editModel, device.model || "");
|
||||
document.getElementById("edit-device-id").value = device.id || "";
|
||||
document.getElementById("edit-device-code").value = device.device_code || device.id || "";
|
||||
document.getElementById("edit-serial-number").value = device.serial_number || "";
|
||||
document.getElementById("edit-communication-mode").value = device.communication_mode || "mqtt";
|
||||
document.getElementById("edit-status").value = device.status || "active";
|
||||
document.getElementById("edit-firmware-version").value = device.firmware_version || "";
|
||||
if (editStatus) {
|
||||
editStatus.textContent = "";
|
||||
editStatus.className = "min-h-5 text-body-md text-slate-500";
|
||||
}
|
||||
if (editSubmitButton) {
|
||||
editSubmitButton.disabled = false;
|
||||
editSubmitButton.textContent = "Save Changes";
|
||||
}
|
||||
editModal.classList.remove("hidden");
|
||||
document.getElementById("edit-serial-number")?.focus();
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
editModal?.classList.add("hidden");
|
||||
activeEditDevice = null;
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeEditDevice || !editSubmitButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialNumber = document.getElementById("edit-serial-number").value.trim();
|
||||
const vendor = editVendor?.value || "";
|
||||
const model = editModel?.value || "";
|
||||
const communicationMode = document.getElementById("edit-communication-mode").value || "mqtt";
|
||||
const status = document.getElementById("edit-status").value || "active";
|
||||
const firmwareVersion = document.getElementById("edit-firmware-version").value.trim();
|
||||
|
||||
if (!serialNumber) {
|
||||
document.getElementById("edit-serial-number")?.focus();
|
||||
if (editStatus) {
|
||||
editStatus.textContent = "Serial number / dev-sn is required.";
|
||||
editStatus.className = "min-h-5 text-body-md text-danger";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
editSubmitButton.disabled = true;
|
||||
editSubmitButton.textContent = "Saving...";
|
||||
if (editStatus) {
|
||||
editStatus.textContent = "Saving device metadata...";
|
||||
editStatus.className = "min-h-5 text-body-md text-slate-500";
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedModel = getCatalogModel(vendor, model);
|
||||
const updated = await api.patchDevice(activeEditDevice.id, {
|
||||
serial_number: serialNumber,
|
||||
vendor,
|
||||
model,
|
||||
communication_mode: communicationMode,
|
||||
capability_profile_json: buildCapabilityProfileForModel(
|
||||
selectedModel,
|
||||
communicationMode,
|
||||
activeEditDevice.capability_profile_json || {}
|
||||
),
|
||||
status,
|
||||
firmware_version: firmwareVersion || undefined
|
||||
});
|
||||
rows = rows.map((item) => (item.id === updated.id ? { ...item, ...updated } : item));
|
||||
if (activeDrawerDevice?.id === updated.id) {
|
||||
activeDrawerDevice = { ...activeDrawerDevice, ...updated };
|
||||
openDrawer(activeDrawerDevice);
|
||||
}
|
||||
applyFilters();
|
||||
closeEditModal();
|
||||
} catch (error) {
|
||||
if (editStatus) {
|
||||
editStatus.textContent = error?.message || "Unable to save device metadata.";
|
||||
editStatus.className = "min-h-5 text-body-md text-danger";
|
||||
}
|
||||
editSubmitButton.disabled = false;
|
||||
editSubmitButton.textContent = "Save Changes";
|
||||
}
|
||||
};
|
||||
|
||||
const loadRegisterOutlets = async (merchantId) => {
|
||||
resetBindingSelects();
|
||||
if (!merchantId || !registerOutlet) {
|
||||
@ -1496,9 +1731,9 @@ Rows
|
||||
Device created
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-slate-500">Device ID</span><p class="font-mono font-bold">${created.id}</p></div>
|
||||
<div><span class="text-slate-500">Serial Number</span><p class="font-mono font-bold">${created.serial_number || "-"}</p></div>
|
||||
<div><span class="text-slate-500">Model</span><p class="font-mono font-bold">${created.model || "-"}</p></div>
|
||||
<div><span class="text-slate-500">Device Code</span><p class="font-mono font-bold">${created.device_code}</p></div>
|
||||
<div><span class="text-slate-500">Serial</span><p class="font-mono font-bold">${created.serial_number || "-"}</p></div>
|
||||
<div><span class="text-slate-500">Binding</span><p class="font-bold">${binding ? "Bound" : "Unassigned"}</p></div>
|
||||
</div>
|
||||
${credentialBlock}
|
||||
@ -1565,8 +1800,8 @@ Rows
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initialQuery = params.get("q") || params.get("focus") || "";
|
||||
if (initialQuery && searchInput && !searchInput.value) {
|
||||
const focusedDevice = rows.find((item) => item.id === initialQuery || item.device_code === initialQuery);
|
||||
searchInput.value = focusedDevice?.device_code || initialQuery;
|
||||
const focusedDevice = rows.find((item) => item.id === initialQuery || item.device_code === initialQuery || item.serial_number === initialQuery);
|
||||
searchInput.value = focusedDevice?.serial_number || initialQuery;
|
||||
}
|
||||
currentPage = 1;
|
||||
applyFilters();
|
||||
@ -1638,6 +1873,24 @@ Rows
|
||||
registerCancelButton?.addEventListener("click", closeRegisterModal);
|
||||
registerOverlay?.addEventListener("click", closeRegisterModal);
|
||||
registerForm?.addEventListener("submit", handleRegisterSubmit);
|
||||
editCloseButton?.addEventListener("click", closeEditModal);
|
||||
editCancelButton?.addEventListener("click", closeEditModal);
|
||||
editOverlay?.addEventListener("click", closeEditModal);
|
||||
editForm?.addEventListener("submit", handleEditSubmit);
|
||||
editVendor?.addEventListener("change", (event) => {
|
||||
hydrateCatalogModelSelect(event.currentTarget.value, editModel);
|
||||
const selectedModel = getCatalogModel(event.currentTarget.value, editModel?.value);
|
||||
if (selectedModel?.communication_mode) {
|
||||
document.getElementById("edit-communication-mode").value = selectedModel.communication_mode;
|
||||
}
|
||||
});
|
||||
editModel?.addEventListener("change", () => {
|
||||
const catalog = deviceCatalog.find((item) => item.vendor === editVendor?.value) || deviceCatalog[0];
|
||||
const selectedModel = (catalog?.models || []).find((item) => item.model === editModel?.value);
|
||||
if (selectedModel?.communication_mode) {
|
||||
document.getElementById("edit-communication-mode").value = selectedModel.communication_mode;
|
||||
}
|
||||
});
|
||||
registerDeviceType?.addEventListener("change", updateCapabilityPreview);
|
||||
registerVendor?.addEventListener("change", (event) => hydrateDeviceModels(event.currentTarget.value));
|
||||
registerModel?.addEventListener("change", () => {
|
||||
|
||||
Reference in New Issue
Block a user