Improve soundbox ops dashboard and registry editing

This commit is contained in:
Wira Basalamah
2026-06-08 15:56:12 +07:00
parent 836eb7db85
commit 67dc286c1a
18 changed files with 768 additions and 120 deletions

View File

@ -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", () => {