Files
Qris-Soundbox/ui/soundbox-catalog/index.html
2026-06-06 20:58:04 +07:00

724 lines
40 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soundbox Catalog | Soundbox Ops</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<style>
body { font-family: Inter, Arial, sans-serif; }
.mono { font-family: "JetBrains Mono", monospace; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<aside class="fixed inset-y-0 left-0 z-50 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<div class="px-2">
<h1 class="text-[22px] font-extrabold leading-tight text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-[12px] font-bold uppercase leading-none text-slate-500">Monitoring Console</p>
</div>
<nav class="mt-8 flex flex-1 flex-col gap-1">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops">
<span class="material-symbols-outlined shrink-0 text-[22px]">monitor_heart</span>
<span class="truncate">Monitoring</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined shrink-0 text-[22px]">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<span class="material-symbols-outlined shrink-0 text-[22px]">lan</span>
<span class="truncate">MQTT Trace</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#config-commands">
<span class="material-symbols-outlined shrink-0 text-[22px]">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg bg-blue-50 px-3 text-[15px] font-semibold leading-none text-blue-700" href="/ui/soundbox-catalog">
<span class="material-symbols-outlined shrink-0 text-[22px]">category</span>
<span class="truncate">Catalog</span>
</a>
</nav>
<div class="border-t border-slate-200 pt-4">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/admin-login">
<span class="material-symbols-outlined shrink-0 text-[22px]">logout</span>
<span class="truncate">Logout</span>
</a>
</div>
</aside>
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/95 px-4 py-4 backdrop-blur lg:ml-64 lg:px-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-sm font-semibold text-slate-500">
<span class="material-symbols-outlined text-[18px]">category</span>
Device master data
</div>
<h2 class="mt-1 text-2xl font-extrabold tracking-normal">Soundbox Catalog</h2>
</div>
<button id="refresh-button" class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-50">
<span class="material-symbols-outlined text-[20px]">sync</span>
Refresh
</button>
</div>
</header>
<main class="lg:ml-64">
<section class="px-4 py-6 lg:px-8">
<div id="alert" class="mb-4 hidden rounded-lg border px-4 py-3 text-sm font-semibold"></div>
<div class="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
<section class="space-y-6">
<form id="vendor-form" class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 id="vendor-form-title" class="text-lg font-extrabold">Vendor</h3>
</div>
<div class="space-y-4 p-5">
<input id="vendor-id" type="hidden" />
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor Code</span>
<input id="vendor-code" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF" required />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor Name</span>
<input id="vendor-name" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF" required />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Country</span>
<input id="vendor-country" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" list="country-options" placeholder="Search country" autocomplete="off" />
<datalist id="country-options"></datalist>
</label>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">PIC Name</span>
<input id="vendor-support-pic" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Contact person" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Phone / WhatsApp</span>
<input id="vendor-support-phone" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="+62..." />
</label>
</div>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Email</span>
<input id="vendor-support-email" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="support@example.com" type="email" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Notes</span>
<textarea id="vendor-support-notes" class="min-h-20 w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Escalation hours, Telegram group, sparepart notes, etc."></textarea>
</label>
<div class="grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<button id="vendor-submit" class="rounded-lg bg-blue-700 px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-800" type="submit">Create Vendor</button>
<button id="vendor-cancel-edit" class="hidden rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
</div>
</div>
</form>
<form id="model-form" class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 id="model-form-title" class="text-lg font-extrabold">Model</h3>
</div>
<div class="space-y-4 p-5">
<input id="model-id" type="hidden" />
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor</span>
<select id="model-vendor" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" required></select>
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Model Code</span>
<input id="model-code" class="w-full rounded-lg border-slate-200 bg-slate-50 text-sm text-slate-500" disabled value="Generated after create" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Name</span>
<input id="model-name" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF100" required />
</label>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Communication</span>
<select id="model-communication" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="mqtt">MQTT</option>
<option value="static">Static</option>
<option value="api">API</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">QR Mode</span>
<select id="model-qr-mode" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="static">Static</option>
<option value="dynamic_mqtt">Dynamic MQTT</option>
<option value="dynamic_api">Dynamic API</option>
</select>
</label>
</div>
<label class="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm font-semibold">
<input id="model-screen" class="rounded border-slate-300 text-blue-700 focus:ring-blue-700" type="checkbox" />
Has screen display
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Payload Profile</span>
<input id="model-payload-profile" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Optional" />
</label>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<span class="mb-2 block text-xs font-bold uppercase text-slate-500">Thumbnail</span>
<div class="flex items-center gap-3">
<div id="model-thumbnail-preview" class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-white text-slate-400">
<span class="material-symbols-outlined text-[28px]">image</span>
</div>
<div class="min-w-0 flex-1">
<input id="model-thumbnail-file" class="block w-full text-xs font-semibold text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-700 file:px-3 file:py-2 file:text-xs file:font-bold file:text-white hover:file:bg-blue-800" type="file" accept="image/*" />
<p class="mt-1 text-xs text-slate-500">One image only, max 512 KB.</p>
</div>
</div>
<button id="model-thumbnail-clear" class="mt-3 hidden rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-100" type="button">Remove Thumbnail</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<button id="model-submit" class="rounded-lg bg-blue-700 px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-800" type="submit">Create Model</button>
<button id="model-cancel-edit" class="hidden rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
</div>
</div>
</form>
</section>
<section class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Active Vendors</p>
<p id="kpi-vendors" class="mt-2 text-3xl font-extrabold">0</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Active Models</p>
<p id="kpi-models" class="mt-2 text-3xl font-extrabold">0</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Dynamic Models</p>
<p id="kpi-dynamic" class="mt-2 text-3xl font-extrabold">0</p>
</article>
</div>
<section class="rounded-lg border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Vendors</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-slate-200 bg-slate-50 text-xs font-bold uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Code</th>
<th class="px-5 py-3">Name</th>
<th class="px-5 py-3">Contact</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3 text-right">Action</th>
</tr>
</thead>
<tbody id="vendor-table" class="divide-y divide-slate-100">
<tr><td class="px-5 py-6 text-center text-slate-500" colspan="5">Loading vendors...</td></tr>
</tbody>
</table>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Models</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-slate-200 bg-slate-50 text-xs font-bold uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Vendor</th>
<th class="px-5 py-3">Model</th>
<th class="px-5 py-3">Mode</th>
<th class="px-5 py-3">Capability</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3 text-right">Action</th>
</tr>
</thead>
<tbody id="model-table" class="divide-y divide-slate-100">
<tr><td class="px-5 py-6 text-center text-slate-500" colspan="6">Loading models...</td></tr>
</tbody>
</table>
</div>
</section>
</section>
</div>
</section>
</main>
<div id="delete-confirm-modal" class="fixed inset-0 z-[80] hidden items-center justify-center bg-slate-950/50 px-4 py-6">
<div class="w-full max-w-md rounded-lg border border-slate-200 bg-white shadow-xl">
<div class="border-b border-slate-200 px-5 py-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-700">
<span class="material-symbols-outlined text-[24px]">delete</span>
</div>
<div class="min-w-0">
<h3 id="delete-confirm-title" class="text-lg font-extrabold">Delete item</h3>
<p id="delete-confirm-message" class="mt-1 text-sm font-medium leading-5 text-slate-600"></p>
</div>
</div>
</div>
<div class="px-5 py-4">
<div class="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm font-semibold text-red-800">
This action cannot be undone.
</div>
</div>
<div class="flex justify-end gap-3 border-t border-slate-200 px-5 py-4">
<button id="delete-confirm-cancel" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
<button id="delete-confirm-submit" class="rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white hover:bg-red-800" type="button">Delete</button>
</div>
</div>
</div>
<script src="/ui/shared/admin-api.js"></script>
<script>
(function () {
const api = window.AdminUIAPI;
const alertBox = document.getElementById("alert");
const deleteConfirmModal = document.getElementById("delete-confirm-modal");
const deleteConfirmTitle = document.getElementById("delete-confirm-title");
const deleteConfirmMessage = document.getElementById("delete-confirm-message");
const deleteConfirmCancel = document.getElementById("delete-confirm-cancel");
const deleteConfirmSubmit = document.getElementById("delete-confirm-submit");
const vendorForm = document.getElementById("vendor-form");
const vendorFormTitle = document.getElementById("vendor-form-title");
const vendorSubmit = document.getElementById("vendor-submit");
const vendorCancelEdit = document.getElementById("vendor-cancel-edit");
const modelForm = document.getElementById("model-form");
const modelFormTitle = document.getElementById("model-form-title");
const modelSubmit = document.getElementById("model-submit");
const modelCancelEdit = document.getElementById("model-cancel-edit");
const modelThumbnailFile = document.getElementById("model-thumbnail-file");
const modelThumbnailPreview = document.getElementById("model-thumbnail-preview");
const modelThumbnailClear = document.getElementById("model-thumbnail-clear");
const vendorTable = document.getElementById("vendor-table");
const modelTable = document.getElementById("model-table");
const modelVendor = document.getElementById("model-vendor");
const countryOptions = document.getElementById("country-options");
let vendors = [];
let models = [];
let modelThumbnailDataUrl = "";
let deleteConfirmResolver = null;
const countryCodes = [
"AF","AX","AL","DZ","AS","AD","AO","AI","AQ","AG","AR","AM","AW","AU","AT","AZ",
"BS","BH","BD","BB","BY","BE","BZ","BJ","BM","BT","BO","BQ","BA","BW","BV","BR",
"IO","BN","BG","BF","BI","CV","KH","CM","CA","KY","CF","TD","CL","CN","CX","CC",
"CO","KM","CG","CD","CK","CR","CI","HR","CU","CW","CY","CZ","DK","DJ","DM","DO",
"EC","EG","SV","GQ","ER","EE","SZ","ET","FK","FO","FJ","FI","FR","GF","PF","TF",
"GA","GM","GE","DE","GH","GI","GR","GL","GD","GP","GU","GT","GG","GN","GW","GY",
"HT","HM","VA","HN","HK","HU","IS","IN","ID","IR","IQ","IE","IM","IL","IT","JM",
"JP","JE","JO","KZ","KE","KI","KP","KR","KW","KG","LA","LV","LB","LS","LR","LY",
"LI","LT","LU","MO","MG","MW","MY","MV","ML","MT","MH","MQ","MR","MU","YT","MX",
"FM","MD","MC","MN","ME","MS","MA","MZ","MM","NA","NR","NP","NL","NC","NZ","NI",
"NE","NG","NU","NF","MK","MP","NO","OM","PK","PW","PS","PA","PG","PY","PE","PH",
"PN","PL","PT","PR","QA","RE","RO","RU","RW","BL","SH","KN","LC","MF","PM","VC",
"WS","SM","ST","SA","SN","RS","SC","SL","SG","SX","SK","SI","SB","SO","ZA","GS",
"SS","ES","LK","SD","SR","SJ","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TK",
"TO","TT","TN","TR","TM","TC","TV","UG","UA","AE","GB","US","UM","UY","UZ","VU",
"VE","VN","VG","VI","WF","EH","YE","ZM","ZW"
];
const hydrateCountryOptions = () => {
if (!countryOptions) {
return;
}
const displayNames = typeof Intl !== "undefined" && Intl.DisplayNames
? new Intl.DisplayNames(["en"], { type: "region" })
: null;
const countries = countryCodes
.map((code) => displayNames ? displayNames.of(code) : code)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const withAliases = Array.from(new Set([...countries, "Palestine", "Palestina"]));
countryOptions.innerHTML = withAliases
.sort((a, b) => a.localeCompare(b))
.map((country) => `<option value="${country}"></option>`)
.join("");
};
const showAlert = (message, type) => {
alertBox.textContent = message;
alertBox.className = `mb-4 rounded-lg border px-4 py-3 text-sm font-semibold ${type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`;
alertBox.classList.remove("hidden");
};
const closeDeleteConfirm = (confirmed) => {
deleteConfirmModal.classList.add("hidden");
deleteConfirmModal.classList.remove("flex");
if (deleteConfirmResolver) {
deleteConfirmResolver(Boolean(confirmed));
deleteConfirmResolver = null;
}
};
const confirmDelete = ({ title, message, buttonLabel }) => {
deleteConfirmTitle.textContent = title;
deleteConfirmMessage.textContent = message;
deleteConfirmSubmit.textContent = buttonLabel || "Delete";
deleteConfirmModal.classList.remove("hidden");
deleteConfirmModal.classList.add("flex");
deleteConfirmCancel.focus();
return new Promise((resolve) => {
deleteConfirmResolver = resolve;
});
};
const statusPill = (status) => {
const active = status === "active";
return `<span class="inline-flex rounded-full border px-2 py-0.5 text-xs font-bold ${active ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-slate-200 bg-slate-100 text-slate-500"}">${active ? "Active" : "Inactive"}</span>`;
};
const resetVendorForm = () => {
vendorForm.reset();
document.getElementById("vendor-id").value = "";
vendorFormTitle.textContent = "Vendor";
vendorSubmit.textContent = "Create Vendor";
vendorCancelEdit.classList.add("hidden");
document.getElementById("vendor-code").disabled = false;
};
const renderModelThumbnailPreview = (thumbnailUrl) => {
if (thumbnailUrl) {
modelThumbnailPreview.innerHTML = `<img class="h-full w-full object-cover" alt="Model thumbnail" src="${thumbnailUrl}" />`;
modelThumbnailClear.classList.remove("hidden");
return;
}
modelThumbnailPreview.innerHTML = '<span class="material-symbols-outlined text-[28px]">image</span>';
modelThumbnailClear.classList.add("hidden");
};
const resetModelForm = () => {
modelForm.reset();
document.getElementById("model-id").value = "";
document.getElementById("model-code").value = "Generated after create";
modelFormTitle.textContent = "Model";
modelSubmit.textContent = "Create Model";
modelCancelEdit.classList.add("hidden");
modelThumbnailDataUrl = "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview("");
};
const fillModelForm = (model) => {
document.getElementById("model-id").value = model.id || "";
modelVendor.value = model.vendor_id || "";
document.getElementById("model-code").value = model.model_code || "Generated after create";
document.getElementById("model-name").value = model.name || "";
document.getElementById("model-communication").value = model.communication_mode || "mqtt";
document.getElementById("model-qr-mode").value = model.qr_mode || "static";
document.getElementById("model-screen").checked = Boolean(model.screen_flag);
document.getElementById("model-payload-profile").value = model.mqtt_payload_profile || "";
modelThumbnailDataUrl = model.thumbnail_url || "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview(modelThumbnailDataUrl);
modelFormTitle.textContent = `Edit Model - ${model.model_code}`;
modelSubmit.textContent = "Update Model";
modelCancelEdit.classList.remove("hidden");
modelForm.scrollIntoView({ behavior: "smooth", block: "start" });
};
const fillVendorForm = (vendor) => {
document.getElementById("vendor-id").value = vendor.id || "";
document.getElementById("vendor-code").value = vendor.vendor_code || "";
document.getElementById("vendor-name").value = vendor.name || "";
document.getElementById("vendor-country").value = vendor.country || "";
document.getElementById("vendor-support-pic").value = vendor.support_pic_name || "";
document.getElementById("vendor-support-phone").value = vendor.support_phone || "";
document.getElementById("vendor-support-email").value = vendor.support_email || "";
document.getElementById("vendor-support-notes").value = vendor.support_notes || vendor.support_contact || "";
vendorFormTitle.textContent = `Edit Vendor - ${vendor.vendor_code}`;
vendorSubmit.textContent = "Update Vendor";
vendorCancelEdit.classList.remove("hidden");
document.getElementById("vendor-code").disabled = true;
vendorForm.scrollIntoView({ behavior: "smooth", block: "start" });
};
const renderVendorOptions = () => {
modelVendor.innerHTML = "";
vendors.forEach((vendor) => {
const option = document.createElement("option");
option.value = vendor.id;
option.textContent = `${vendor.vendor_code} - ${vendor.name}`;
modelVendor.appendChild(option);
});
};
const renderVendors = () => {
document.getElementById("kpi-vendors").textContent = String(vendors.filter((item) => item.status === "active").length);
if (!vendors.length) {
vendorTable.innerHTML = '<tr><td class="px-5 py-6 text-center text-slate-500" colspan="5">No vendors yet.</td></tr>';
return;
}
vendorTable.innerHTML = vendors.map((vendor) => `
<tr>
<td class="px-5 py-4 mono font-bold text-blue-700">${vendor.vendor_code}</td>
<td class="px-5 py-4 font-semibold">${vendor.name}</td>
<td class="px-5 py-4 text-slate-600">
<div class="font-semibold text-slate-800">${vendor.support_pic_name || vendor.support_contact || "-"}</div>
<div class="text-xs">${vendor.support_phone || "-"}</div>
<div class="text-xs">${vendor.support_email || vendor.country || "-"}</div>
</td>
<td class="px-5 py-4">${statusPill(vendor.status)}</td>
<td class="px-5 py-4 text-right">
<div class="flex justify-end gap-2">
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-blue-700 hover:bg-blue-50" data-action="edit-vendor" data-id="${vendor.id}">Edit</button>
<button class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-bold text-red-700 hover:bg-red-50" data-action="delete-vendor" data-id="${vendor.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
vendorTable.querySelectorAll("[data-action='edit-vendor']").forEach((button) => {
button.addEventListener("click", (event) => {
const vendor = vendors.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (vendor) {
fillVendorForm(vendor);
}
});
});
vendorTable.querySelectorAll("[data-action='delete-vendor']").forEach((button) => {
button.addEventListener("click", async (event) => {
const vendor = vendors.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (!vendor) {
return;
}
const confirmed = await confirmDelete({
title: "Delete vendor",
message: `Delete ${vendor.vendor_code} - ${vendor.name}? Vendor can only be deleted when it has no models.`,
buttonLabel: "Delete Vendor"
});
if (!confirmed) {
return;
}
try {
await api.deleteSoundboxVendor(vendor.id);
if (document.getElementById("vendor-id").value === vendor.id) {
resetVendorForm();
}
showAlert("Vendor deleted.", "success");
await refresh();
} catch (error) {
showAlert(error.message || "Unable to delete vendor.", "error");
}
});
});
};
const renderModels = () => {
document.getElementById("kpi-models").textContent = String(models.filter((item) => item.status === "active").length);
document.getElementById("kpi-dynamic").textContent = String(models.filter((item) => item.qr_mode !== "static").length);
if (!models.length) {
modelTable.innerHTML = '<tr><td class="px-5 py-6 text-center text-slate-500" colspan="6">No models yet.</td></tr>';
return;
}
modelTable.innerHTML = models.map((model) => `
<tr>
<td class="px-5 py-4 mono text-slate-600">${model.vendor_code || "-"}</td>
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-slate-50 text-slate-400">
${model.thumbnail_url ? `<img class="h-full w-full object-cover" alt="${model.name}" src="${model.thumbnail_url}" />` : '<span class="material-symbols-outlined text-[22px]">image</span>'}
</div>
<div>
<div class="font-bold">${model.model_code}</div>
<div class="text-xs text-slate-500">${model.name}</div>
</div>
</div>
</td>
<td class="px-5 py-4">
<div class="font-semibold uppercase">${model.communication_mode}</div>
<div class="mono text-xs text-slate-500">${model.mqtt_payload_profile || "-"}</div>
</td>
<td class="px-5 py-4">
<span class="inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-bold text-slate-700">${model.screen_flag ? "Screen" : "Sound only"}</span>
<span class="ml-1 inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-bold text-slate-700">${model.qr_mode}</span>
</td>
<td class="px-5 py-4">${statusPill(model.status)}</td>
<td class="px-5 py-4 text-right">
<div class="flex justify-end gap-2">
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-blue-700 hover:bg-blue-50" data-action="edit-model" data-id="${model.id}">Edit</button>
<button class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-bold text-red-700 hover:bg-red-50" data-action="delete-model" data-id="${model.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
modelTable.querySelectorAll("[data-action='edit-model']").forEach((button) => {
button.addEventListener("click", (event) => {
const model = models.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (model) {
fillModelForm(model);
}
});
});
modelTable.querySelectorAll("[data-action='delete-model']").forEach((button) => {
button.addEventListener("click", async (event) => {
const model = models.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (!model) {
return;
}
const confirmed = await confirmDelete({
title: "Delete model",
message: `Delete ${model.model_code} - ${model.name}?`,
buttonLabel: "Delete Model"
});
if (!confirmed) {
return;
}
try {
await api.deleteSoundboxModel(model.id);
if (document.getElementById("model-id").value === model.id) {
resetModelForm();
}
showAlert("Model deleted.", "success");
await refresh();
} catch (error) {
showAlert(error.message || "Unable to delete model.", "error");
}
});
});
};
const refresh = async () => {
try {
api.requireToken();
const [vendorRows, modelRows] = await Promise.all([
api.listSoundboxVendors(),
api.listSoundboxModels()
]);
vendors = Array.isArray(vendorRows) ? vendorRows : [];
models = Array.isArray(modelRows) ? modelRows : [];
renderVendorOptions();
renderVendors();
renderModels();
} catch (error) {
showAlert(error.message || "Unable to load catalog.", "error");
}
};
vendorForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const vendorId = document.getElementById("vendor-id").value;
const payload = {
name: document.getElementById("vendor-name").value.trim(),
country: document.getElementById("vendor-country").value.trim() || undefined,
support_pic_name: document.getElementById("vendor-support-pic").value.trim() || undefined,
support_email: document.getElementById("vendor-support-email").value.trim() || undefined,
support_phone: document.getElementById("vendor-support-phone").value.trim() || undefined,
support_notes: document.getElementById("vendor-support-notes").value.trim() || undefined,
status: "active"
};
if (vendorId) {
await api.patchSoundboxVendor(vendorId, payload);
showAlert("Vendor updated.", "success");
} else {
await api.createSoundboxVendor({
...payload,
vendor_code: document.getElementById("vendor-code").value.trim()
});
showAlert("Vendor created.", "success");
}
resetVendorForm();
await refresh();
} catch (error) {
showAlert(error.message || "Unable to save vendor.", "error");
}
});
vendorCancelEdit.addEventListener("click", resetVendorForm);
modelThumbnailFile.addEventListener("change", () => {
const file = modelThumbnailFile.files && modelThumbnailFile.files[0];
if (!file) {
return;
}
if (!file.type.startsWith("image/")) {
showAlert("Thumbnail must be an image file.", "error");
modelThumbnailFile.value = "";
return;
}
if (file.size > 512 * 1024) {
showAlert("Thumbnail maximum size is 512 KB.", "error");
modelThumbnailFile.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
modelThumbnailDataUrl = String(reader.result || "");
renderModelThumbnailPreview(modelThumbnailDataUrl);
};
reader.onerror = () => showAlert("Unable to read thumbnail file.", "error");
reader.readAsDataURL(file);
});
modelThumbnailClear.addEventListener("click", () => {
modelThumbnailDataUrl = "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview("");
});
modelForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const modelId = document.getElementById("model-id").value;
const screen = document.getElementById("model-screen").checked;
const qrMode = document.getElementById("model-qr-mode").value;
const payloadProfile = document.getElementById("model-payload-profile").value.trim();
const payload = {
vendor_id: modelVendor.value,
name: document.getElementById("model-name").value.trim(),
communication_mode: document.getElementById("model-communication").value,
screen_flag: screen,
qr_mode: qrMode,
mqtt_payload_profile: payloadProfile || undefined,
thumbnail_url: modelThumbnailDataUrl || undefined,
capability_template_json: {
device_type: screen ? "dynamic_screen_soundbox" : "static_soundbox",
screen,
qr_mode: qrMode,
...(payloadProfile ? { mqtt_payload_profile: payloadProfile } : {}),
flows: screen ? ["static_payment_notification", "dynamic_qr:mqtt"] : ["static_payment_notification"],
features: {
payment_sound: true,
dynamic_qr: screen ? { mqtt: qrMode === "dynamic_mqtt", display: "screen" } : false
}
},
status: "active"
};
if (modelId) {
await api.patchSoundboxModel(modelId, payload);
showAlert("Model updated.", "success");
} else {
await api.createSoundboxModel(payload);
showAlert("Model created.", "success");
}
resetModelForm();
await refresh();
} catch (error) {
showAlert(error.message || "Unable to save model.", "error");
}
});
modelCancelEdit.addEventListener("click", resetModelForm);
deleteConfirmCancel.addEventListener("click", () => closeDeleteConfirm(false));
deleteConfirmSubmit.addEventListener("click", () => closeDeleteConfirm(true));
deleteConfirmModal.addEventListener("click", (event) => {
if (event.target === deleteConfirmModal) {
closeDeleteConfirm(false);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteConfirmModal.classList.contains("hidden")) {
closeDeleteConfirm(false);
}
});
document.getElementById("refresh-button").addEventListener("click", refresh);
hydrateCountryOptions();
refresh();
})();
</script>
</body>
</html>