Improve soundbox ops dashboard and registry editing
This commit is contained in:
@ -1,9 +1,46 @@
|
|||||||
# Codex Handoff - QRIS Soundbox Platform
|
# Codex Handoff - QRIS Soundbox Platform
|
||||||
|
|
||||||
Tanggal update: 2026-06-07, Asia/Jakarta.
|
Tanggal update: 2026-06-08, Asia/Jakarta.
|
||||||
|
|
||||||
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
|
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
|
||||||
|
|
||||||
|
## Update Terbaru - 2026-06-08
|
||||||
|
|
||||||
|
- `soundbox-backend-mqtt-spec.md` sekarang mendokumentasikan device command QF100 category `5` untuk `reboot` dan `poweroff`.
|
||||||
|
- Backend sudah sinkron dengan spec command tersebut:
|
||||||
|
- `POST /admin/devices/{id}/commands` menerima `device.poweroff`;
|
||||||
|
- publisher MQTT membuat payload QF100 category `5` dengan `data.command = "poweroff"`;
|
||||||
|
- downlink tercatat sebagai `poweroff_command` di `mqtt_messages`.
|
||||||
|
- `scripts/smoke-qf100-adapter.mjs` sudah menambahkan assertion untuk command `device.poweroff`.
|
||||||
|
- Dashboard `/ui/soundbox-ops` dirapikan untuk operator:
|
||||||
|
- KPI warning sekarang menunjukkan breakdown stale vs degraded;
|
||||||
|
- KPI card bisa dipakai sebagai quick filter;
|
||||||
|
- tabel Fleet Status menampilkan health bar, reason, signal, dan battery;
|
||||||
|
- Device ID menjadi link langsung ke technical detail;
|
||||||
|
- Remote Actions menambahkan tombol `Power Off Device`;
|
||||||
|
- layout mobile header/filter dibuat full-width agar tidak overflow.
|
||||||
|
- Registry `/ui/device-registry-monitoring` sekarang mendukung koreksi device metadata:
|
||||||
|
- menu row punya `Edit Device`;
|
||||||
|
- modal edit bisa koreksi `serial_number/dev-sn`, vendor, model, communication mode, status, dan firmware version;
|
||||||
|
- perubahan model ikut memperbarui `capability_profile_json` dari katalog model aktif;
|
||||||
|
- backend menolak duplicate `serial_number` supaya config pull/MQTT lookup tidak ambigu.
|
||||||
|
- Search UI yang sebelumnya dekoratif sudah mulai difungsikan:
|
||||||
|
- Admin Dashboard global search route ke Device Registry, Merchant List, atau Transaction History dengan `?q=`;
|
||||||
|
- Transaction History dan Merchant List membaca `?q=` sebagai initial search;
|
||||||
|
- Admin Reconciliation top search route ke Transaction History;
|
||||||
|
- Settlement Batch search live-filter batch table;
|
||||||
|
- Merchant Settlement History search live-filter disbursement table dan membaca `?q=`;
|
||||||
|
- Merchant Dashboard search route ke Merchant Settlement History;
|
||||||
|
- Fee Pricing search route ke Audit Logs, dan Audit Logs membaca `?q=`;
|
||||||
|
- Merchant Detail search route ke Merchant List;
|
||||||
|
- Device QR Payment Display search live-filter transaction rows.
|
||||||
|
- Verifikasi lokal update ini:
|
||||||
|
- `npm run typecheck`: pass;
|
||||||
|
- `node --check scripts/smoke-qf100-adapter.mjs`: pass.
|
||||||
|
- `node scripts/ui-qa-check.mjs`: pass;
|
||||||
|
- direct script parse `ui/device-registry-monitoring/index.html`: pass;
|
||||||
|
- headless Chrome screenshot desktop/mobile `/ui/soundbox-ops/?preview=1`: pass visual sanity.
|
||||||
|
|
||||||
## Update Terbaru - 2026-06-07
|
## Update Terbaru - 2026-06-07
|
||||||
|
|
||||||
- Production saat ini fokus ke portal Soundbox Ops di `sms.bizone.id`, dengan MQTT broker `broker.bizone.id`.
|
- Production saat ini fokus ke portal Soundbox Ops di `sms.bizone.id`, dengan MQTT broker `broker.bizone.id`.
|
||||||
|
|||||||
@ -262,6 +262,25 @@ async function triggerRebootCommand({ bundle }) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerPoweroffCommand({ bundle }) {
|
||||||
|
const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
command: "device.poweroff",
|
||||||
|
payload: {
|
||||||
|
requested_from: "qf100_smoke"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_label: "POST /admin/devices/:id/commands poweroff"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(result.status === "delivered", "poweroff command must be delivered");
|
||||||
|
assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "poweroff topic must use serial number");
|
||||||
|
assert(result.result_payload?.payload?.header?.category === 5, "poweroff category must be 5");
|
||||||
|
assert(result.result_payload?.payload?.data?.command === "poweroff", "poweroff command payload must be poweroff");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await req("/health", { _label: "GET /health" });
|
await req("/health", { _label: "GET /health" });
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
@ -294,6 +313,7 @@ async function main() {
|
|||||||
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
||||||
const dynamicQrDisplay = await triggerDynamicQrDisplay({ bundle: dynamicBundle });
|
const dynamicQrDisplay = await triggerDynamicQrDisplay({ bundle: dynamicBundle });
|
||||||
const rebootCommand = await triggerRebootCommand({ bundle: staticBundle });
|
const rebootCommand = await triggerRebootCommand({ bundle: staticBundle });
|
||||||
|
const poweroffCommand = await triggerPoweroffCommand({ bundle: staticBundle });
|
||||||
|
|
||||||
console.log("\nQF100 adapter smoke passed");
|
console.log("\nQF100 adapter smoke passed");
|
||||||
console.log(`static_sn=${STATIC_SN}`);
|
console.log(`static_sn=${STATIC_SN}`);
|
||||||
@ -304,6 +324,7 @@ async function main() {
|
|||||||
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
|
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
|
||||||
console.log(`dynamic_qr_display_command=${dynamicQrDisplay.id || dynamicQrDisplay.command_id}`);
|
console.log(`dynamic_qr_display_command=${dynamicQrDisplay.id || dynamicQrDisplay.command_id}`);
|
||||||
console.log(`reboot_command=${rebootCommand.id || rebootCommand.command_id}`);
|
console.log(`reboot_command=${rebootCommand.id || rebootCommand.command_id}`);
|
||||||
|
console.log(`poweroff_command=${poweroffCommand.id || poweroffCommand.command_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@ -20,6 +20,11 @@ Current firmware mode:
|
|||||||
|
|
||||||
- `SAMPLE_MQTT_DEMO` is enabled by default.
|
- `SAMPLE_MQTT_DEMO` is enabled by default.
|
||||||
- `MQTT_STRICT_TEST_DEMO` is disabled unless explicitly enabled in firmware.
|
- `MQTT_STRICT_TEST_DEMO` is disabled unless explicitly enabled in firmware.
|
||||||
|
- QF100 reports `dev-model` as `EDOTF1`.
|
||||||
|
- `DEFAULT_LANGUAGE` is `2`, used here as Indonesian/IDR mode.
|
||||||
|
- Payment and QR amounts are treated as whole rupiah, not cents.
|
||||||
|
- The QR amount label shown on screen is `Nominal: Rp <amount>`.
|
||||||
|
- The status screen shows battery percentage and network signal with `dBm`.
|
||||||
|
|
||||||
## 2. Device Config API
|
## 2. Device Config API
|
||||||
|
|
||||||
@ -41,7 +46,7 @@ Transport:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dev-model": "QF100",
|
"dev-model": "EDOTF1",
|
||||||
"item-number": "00",
|
"item-number": "00",
|
||||||
"dev-sn": "DEVICE_SN",
|
"dev-sn": "DEVICE_SN",
|
||||||
"hardware-config": "0x0F",
|
"hardware-config": "0x0F",
|
||||||
@ -129,6 +134,8 @@ Behavior:
|
|||||||
|
|
||||||
- The device formats `pay-amount` into a 12-digit string.
|
- The device formats `pay-amount` into a 12-digit string.
|
||||||
- Example: `15000` becomes `000000015000`.
|
- Example: `15000` becomes `000000015000`.
|
||||||
|
- In the current Indonesian/IDR firmware, the value is treated as whole rupiah.
|
||||||
|
- Example: `pay-amount: 30000` is displayed as `30000` and spoken as `tiga puluh ribu rupiah`.
|
||||||
- The device displays the amount and plays the payment audio.
|
- The device displays the amount and plays the payment audio.
|
||||||
|
|
||||||
Do not send `pay-amount` as a string:
|
Do not send `pay-amount` as a string:
|
||||||
@ -245,13 +252,16 @@ Behavior:
|
|||||||
|
|
||||||
- The device displays the QR code immediately after receiving a valid category `4` payload.
|
- The device displays the QR code immediately after receiving a valid category `4` payload.
|
||||||
- The QR page shows `data.qr-url` as QR content and displays `data.amount`.
|
- The QR page shows `data.qr-url` as QR content and displays `data.amount`.
|
||||||
|
- In the current Indonesian/IDR firmware, the amount is shown as `Nominal: Rp <amount>`.
|
||||||
- When `expire-seconds` elapses, the device returns to the default `Bizone System` status screen.
|
- When `expire-seconds` elapses, the device returns to the default `Bizone System` status screen.
|
||||||
- If a category `1` payment notification is received while the QR page is active, the device immediately hides the QR page and returns to the default status screen before playing the payment notification.
|
- If a category `1` payment notification is received while the QR page is active, the device immediately hides the QR page and returns to the default status screen before playing the payment notification.
|
||||||
- This command only controls the display. It does not start the internal POS QR transaction flow.
|
- This command only controls the display. It does not start the internal POS QR transaction flow.
|
||||||
|
|
||||||
## 6. MQTT Reboot Command Payload
|
## 6. MQTT Device Command Payload
|
||||||
|
|
||||||
The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic`:
|
The backend can ask the device to run a device-level command by publishing to `mqtt.subscribe-topic`.
|
||||||
|
|
||||||
|
Reboot command:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -264,18 +274,32 @@ The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic`
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Power off command:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"category": 5
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"command": "poweroff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Required Fields
|
### Required Fields
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
| Field | Type | Required | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `header.category` | number | yes | Use `5` for device reboot command. |
|
| `header.category` | number | yes | Use `5` for device command. |
|
||||||
| `data.command` | string | yes | Must be exactly `reboot`. |
|
| `data.command` | string | yes | Supported values: `reboot`, `poweroff`. |
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- The device validates `data.command` before rebooting.
|
- The device validates `data.command` before executing the command.
|
||||||
- If `data.command` is missing or not exactly `reboot`, the payload is ignored.
|
- If `data.command` is missing or not one of the supported values, the payload is ignored.
|
||||||
- When valid, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts.
|
- For `reboot`, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts.
|
||||||
|
- For `poweroff`, the device plays the power-off audio, shows `Shutting down...`, waits about 2 seconds, then powers off.
|
||||||
- This command is backend-to-device only.
|
- This command is backend-to-device only.
|
||||||
|
|
||||||
## 7. MQTT Device Heartbeat
|
## 7. MQTT Device Heartbeat
|
||||||
@ -360,6 +384,7 @@ Important:
|
|||||||
- `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent.
|
- `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent.
|
||||||
- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. The firmware avoids WiFi scanning inside routine heartbeat to keep heartbeat stable.
|
- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. The firmware avoids WiFi scanning inside routine heartbeat to keep heartbeat stable.
|
||||||
- `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo.
|
- `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo.
|
||||||
|
- On the device status screen, GPRS signal quality `1..31` is converted to approximate dBm with `-113 + 2 * rssi` before display. The heartbeat payload still sends the SDK/modem `rssi` value.
|
||||||
|
|
||||||
## 8. Unsupported Categories
|
## 8. Unsupported Categories
|
||||||
|
|
||||||
@ -374,7 +399,7 @@ For unsupported categories, the firmware still requires:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action.
|
The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `5` supports `data.command` values `reboot` and `poweroff`. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action.
|
||||||
|
|
||||||
## 9. OTA Check API Response
|
## 9. OTA Check API Response
|
||||||
|
|
||||||
@ -567,6 +592,27 @@ Payload:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Power Off Command Publish
|
||||||
|
|
||||||
|
Publish to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
soundbox/QF100123456/down
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"category": 5
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"command": "poweroff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Heartbeat From Device
|
### Heartbeat From Device
|
||||||
|
|
||||||
Subscribe to:
|
Subscribe to:
|
||||||
@ -598,7 +644,7 @@ Payload received:
|
|||||||
- Always publish valid JSON.
|
- Always publish valid JSON.
|
||||||
- Always use JSON numbers for numeric fields.
|
- Always use JSON numbers for numeric fields.
|
||||||
- Do not use strings for `category`, `pay-amount`, `amount`, `expire-seconds`, `fw-build`, `broker-port`, or `keep-alive`.
|
- Do not use strings for `category`, `pay-amount`, `amount`, `expire-seconds`, `fw-build`, `broker-port`, or `keep-alive`.
|
||||||
- For reboot, send `data.command` exactly as the string `reboot`.
|
- For device commands, send `data.command` exactly as one of the supported strings: `reboot` or `poweroff`.
|
||||||
- Keep `client-id` unique per device.
|
- Keep `client-id` unique per device.
|
||||||
- Use the device serial number `dev-sn` as the main device identifier.
|
- Use the device serial number `dev-sn` as the main device identifier.
|
||||||
- The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads.
|
- The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads.
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBin
|
|||||||
import {
|
import {
|
||||||
createDevice,
|
createDevice,
|
||||||
getDeviceById,
|
getDeviceById,
|
||||||
|
getDeviceBySerialNumber,
|
||||||
listDevices,
|
listDevices,
|
||||||
patchDevice,
|
patchDevice,
|
||||||
rotateDeviceMqttCredential,
|
rotateDeviceMqttCredential,
|
||||||
@ -99,7 +100,7 @@ import {
|
|||||||
upsertDeviceConfig
|
upsertDeviceConfig
|
||||||
} from "../shared/store/deviceConfigStore";
|
} from "../shared/store/deviceConfigStore";
|
||||||
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
||||||
import { getMqttPublisherStatus, publishConfigPush, publishQf100DynamicQrDisplay, publishQf100RebootCommand } from "../shared/services/mqttPublisher";
|
import { getMqttPublisherStatus, publishConfigPush, publishQf100DeviceCommand, publishQf100DynamicQrDisplay } from "../shared/services/mqttPublisher";
|
||||||
import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber";
|
import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber";
|
||||||
import { getDatabaseHealth, getServiceHealth } from "../shared/services/health";
|
import { getDatabaseHealth, getServiceHealth } from "../shared/services/health";
|
||||||
import { logger } from "../shared/services/logger";
|
import { logger } from "../shared/services/logger";
|
||||||
@ -1523,6 +1524,29 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create",
|
|||||||
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serialNumber = payload.serial_number?.trim();
|
||||||
|
if (payload.serial_number !== undefined) {
|
||||||
|
if (!serialNumber) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "serial_number must not be empty", 400));
|
||||||
|
}
|
||||||
|
payload.serial_number = serialNumber;
|
||||||
|
}
|
||||||
|
if (payload.vendor !== undefined) {
|
||||||
|
payload.vendor = payload.vendor.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (payload.model !== undefined) {
|
||||||
|
payload.model = payload.model.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (payload.firmware_version !== undefined) {
|
||||||
|
payload.firmware_version = payload.firmware_version.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (serialNumber) {
|
||||||
|
const existingSerialDevice = await getDeviceBySerialNumber(serialNumber);
|
||||||
|
if (existingSerialDevice) {
|
||||||
|
return next(new ApiError("CONFLICT", "serial_number already belongs to another device", 409));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await createDevice(payload);
|
const created = await createDevice(payload);
|
||||||
await auditAdminAction(req, {
|
await auditAdminAction(req, {
|
||||||
action: "device.create",
|
action: "device.create",
|
||||||
@ -1686,6 +1710,29 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res:
|
|||||||
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serialNumber = payload.serial_number?.trim();
|
||||||
|
if (payload.serial_number !== undefined) {
|
||||||
|
if (!serialNumber) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "serial_number must not be empty", 400));
|
||||||
|
}
|
||||||
|
payload.serial_number = serialNumber;
|
||||||
|
}
|
||||||
|
if (payload.vendor !== undefined) {
|
||||||
|
payload.vendor = payload.vendor.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (payload.model !== undefined) {
|
||||||
|
payload.model = payload.model.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (payload.firmware_version !== undefined) {
|
||||||
|
payload.firmware_version = payload.firmware_version.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (serialNumber) {
|
||||||
|
const serialDevice = await getDeviceBySerialNumber(serialNumber);
|
||||||
|
if (serialDevice && serialDevice.id !== req.params.deviceId) {
|
||||||
|
return next(new ApiError("CONFLICT", "serial_number already belongs to another device", 409));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await getDeviceById(req.params.deviceId);
|
const existing = await getDeviceById(req.params.deviceId);
|
||||||
const updated = await patchDevice(req.params.deviceId, payload);
|
const updated = await patchDevice(req.params.deviceId, payload);
|
||||||
@ -1894,7 +1941,7 @@ router.post(
|
|||||||
return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command)));
|
return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandName === "device.reboot") {
|
if (commandName === "device.reboot" || commandName === "device.poweroff") {
|
||||||
if (!device.serial_number) {
|
if (!device.serial_number) {
|
||||||
const failed = await acknowledgeDeviceCommand({
|
const failed = await acknowledgeDeviceCommand({
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
@ -1906,12 +1953,13 @@ router.post(
|
|||||||
return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command)));
|
return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishResult = await publishQf100RebootCommand(device.id, device.serial_number);
|
const deviceCommand = commandName === "device.poweroff" ? "poweroff" : "reboot";
|
||||||
|
const publishResult = await publishQf100DeviceCommand(device.id, device.serial_number, deviceCommand);
|
||||||
await createMqttMessage({
|
await createMqttMessage({
|
||||||
direction: "downlink",
|
direction: "downlink",
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
topic: publishResult.topic,
|
topic: publishResult.topic,
|
||||||
message_type: "reboot_command",
|
message_type: `${deviceCommand}_command`,
|
||||||
correlation_id: command.id,
|
correlation_id: command.id,
|
||||||
payload_json: publishResult.payload,
|
payload_json: publishResult.payload,
|
||||||
publish_status: publishResult.ok ? "sent" : "failed",
|
publish_status: publishResult.ok ? "sent" : "failed",
|
||||||
|
|||||||
@ -38,12 +38,14 @@ type Qf100DynamicQrDisplayPayload = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Qf100RebootCommandPayload = {
|
type Qf100DeviceCommand = "reboot" | "poweroff";
|
||||||
|
|
||||||
|
type Qf100DeviceCommandPayload = {
|
||||||
header: {
|
header: {
|
||||||
category: 5;
|
category: 5;
|
||||||
};
|
};
|
||||||
data: {
|
data: {
|
||||||
command: "reboot";
|
command: Qf100DeviceCommand;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -330,20 +332,28 @@ export async function publishQf100DynamicQrDisplay(
|
|||||||
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), qrPayload);
|
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), qrPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishQf100RebootCommand(
|
export async function publishQf100DeviceCommand(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
serialNumber: string
|
serialNumber: string,
|
||||||
): Promise<MqttPublishResult<Qf100RebootCommandPayload>> {
|
command: Qf100DeviceCommand
|
||||||
const rebootPayload: Qf100RebootCommandPayload = {
|
): Promise<MqttPublishResult<Qf100DeviceCommandPayload>> {
|
||||||
|
const commandPayload: Qf100DeviceCommandPayload = {
|
||||||
header: {
|
header: {
|
||||||
category: 5
|
category: 5
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
command: "reboot"
|
command
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), rebootPayload);
|
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), commandPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishQf100RebootCommand(
|
||||||
|
deviceId: string,
|
||||||
|
serialNumber: string
|
||||||
|
): Promise<MqttPublishResult<Qf100DeviceCommandPayload>> {
|
||||||
|
return publishQf100DeviceCommand(deviceId, serialNumber, "reboot");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) {
|
export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) {
|
||||||
|
|||||||
@ -182,7 +182,7 @@
|
|||||||
<div class="flex items-center gap-3 lg:gap-6 flex-1 min-w-0">
|
<div class="flex items-center gap-3 lg:gap-6 flex-1 min-w-0">
|
||||||
<div class="relative w-full max-w-md hidden sm:block">
|
<div class="relative w-full max-w-md hidden sm:block">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-body-lg">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-body-lg">search</span>
|
||||||
<input class="w-full pl-10 pr-4 py-2 bg-slate-100 dark:bg-slate-800 border-none rounded-full focus:ring-2 focus:ring-primary/20 text-body-md" placeholder="Search devices, merchants, or transactions..." type="text"/>
|
<input id="dashboard-global-search" class="w-full pl-10 pr-4 py-2 bg-slate-100 dark:bg-slate-800 border-none rounded-full focus:ring-2 focus:ring-primary/20 text-body-md" placeholder="Search devices, merchants, or transactions..." type="text"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 overflow-x-auto whitespace-nowrap">
|
<div class="flex items-center gap-4 overflow-x-auto whitespace-nowrap">
|
||||||
<a class="text-primary dark:text-primary-fixed border-b-2 border-primary h-[72px] flex items-center px-2 font-bold" href="/ui/admin-dashboard-overview">Dashboard</a>
|
<a class="text-primary dark:text-primary-fixed border-b-2 border-primary h-[72px] flex items-center px-2 font-bold" href="/ui/admin-dashboard-overview">Dashboard</a>
|
||||||
@ -1004,6 +1004,23 @@
|
|||||||
|
|
||||||
AdminDashboard.load();
|
AdminDashboard.load();
|
||||||
|
|
||||||
|
const dashboardSearch = document.getElementById("dashboard-global-search");
|
||||||
|
dashboardSearch?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && dashboardSearch.value) {
|
||||||
|
dashboardSearch.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const q = dashboardSearch.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const target = /^(tx|txn|rrn|pay|ref)/i.test(q)
|
||||||
|
? "/ui/transaction-history-monitoring"
|
||||||
|
: /^(mch|merchant|biz|store)/i.test(q)
|
||||||
|
? "/ui/merchant-list-management"
|
||||||
|
: "/ui/device-registry-monitoring";
|
||||||
|
window.location.href = `${target}?q=${encodeURIComponent(q)}`;
|
||||||
|
});
|
||||||
|
|
||||||
// Micro-interactions for hovering and state visual feedback
|
// Micro-interactions for hovering and state visual feedback
|
||||||
document.querySelectorAll(".hover\\:shadow-lg").forEach((card) => {
|
document.querySelectorAll(".hover\\:shadow-lg").forEach((card) => {
|
||||||
card.addEventListener("mouseenter", () => {
|
card.addEventListener("mouseenter", () => {
|
||||||
|
|||||||
@ -177,7 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<input class="bg-slate-100 border-none rounded-full px-4 py-2 text-body-md w-64 focus:ring-2 focus:ring-primary transition-all" placeholder="Search operations..." type="text"/>
|
<input id="fee-pricing-search" class="bg-slate-100 border-none rounded-full px-4 py-2 text-body-md w-64 focus:ring-2 focus:ring-primary transition-all" placeholder="Search operations..." type="text"/>
|
||||||
<span class="material-symbols-outlined absolute right-3 top-2 text-slate-400">search</span>
|
<span class="material-symbols-outlined absolute right-3 top-2 text-slate-400">search</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="p-2 text-slate-500 hover:bg-slate-100 rounded-full transition-colors">
|
<button class="p-2 text-slate-500 hover:bg-slate-100 rounded-full transition-colors">
|
||||||
@ -552,6 +552,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Dashboard Interaction
|
// Dashboard Interaction
|
||||||
|
document.getElementById('fee-pricing-search')?.addEventListener('keydown', (event) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
if (event.key === 'Escape' && input.value) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && input.value.trim()) {
|
||||||
|
window.location.href = `/ui/admin-system-audit-logs?q=${encodeURIComponent(input.value.trim())}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const cards = document.querySelectorAll('.lg\\:col-span-4 .bg-white');
|
const cards = document.querySelectorAll('.lg\\:col-span-4 .bg-white');
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
|||||||
@ -182,7 +182,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<input class="pl-10 pr-4 py-2 rounded-full border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary/20 w-64 bg-slate-50 transition-all focus:bg-white text-body-md" placeholder="Search transactions..." type="text"/>
|
<input id="reconciliation-global-search" class="pl-10 pr-4 py-2 rounded-full border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary/20 w-64 bg-slate-50 transition-all focus:bg-white text-body-md" placeholder="Search transactions..." type="text"/>
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" data-icon="search">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" data-icon="search">search</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors text-slate-600">
|
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors text-slate-600">
|
||||||
@ -912,6 +912,16 @@
|
|||||||
document.getElementById('clear-adjustment-filter')?.addEventListener('click', clearAdjustmentFilter);
|
document.getElementById('clear-adjustment-filter')?.addEventListener('click', clearAdjustmentFilter);
|
||||||
document.getElementById('recon-adjustment-activity')?.addEventListener('click', handleAdjustmentApproval);
|
document.getElementById('recon-adjustment-activity')?.addEventListener('click', handleAdjustmentApproval);
|
||||||
document.getElementById('adjustment-export-history')?.addEventListener('click', handleExportHistoryClick);
|
document.getElementById('adjustment-export-history')?.addEventListener('click', handleExportHistoryClick);
|
||||||
|
document.getElementById('reconciliation-global-search')?.addEventListener('keydown', (event) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
if (event.key === 'Escape' && input.value) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && input.value.trim()) {
|
||||||
|
window.location.href = `/ui/transaction-history-monitoring?q=${encodeURIComponent(input.value.trim())}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<!-- ui-nav -->
|
<!-- ui-nav -->
|
||||||
|
|||||||
@ -673,6 +673,12 @@
|
|||||||
api.requireToken();
|
api.requireToken();
|
||||||
auditLogs = await api.listAuditLogs(buildQuery());
|
auditLogs = await api.listAuditLogs(buildQuery());
|
||||||
renderRows();
|
renderRows();
|
||||||
|
const initialQuery = new URLSearchParams(window.location.search).get('q') || '';
|
||||||
|
const auditSearch = document.getElementById('audit-search');
|
||||||
|
if (initialQuery && auditSearch && !auditSearch.value) {
|
||||||
|
auditSearch.value = initialQuery;
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const tbody = document.getElementById('audit-log-rows');
|
const tbody = document.getElementById('audit-log-rows');
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
|
|||||||
@ -278,7 +278,7 @@ Register
|
|||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-slate-50 border-b border-slate-200">
|
<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">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">Merchant Binding</th>
|
||||||
<th class="px-6 py-4 font-bold text-label-md text-slate-500 uppercase tracking-wider">Connection</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- 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-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">
|
<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 registerCapabilityPreview = document.getElementById("register-capability-preview");
|
||||||
const registerVendor = document.getElementById("register-vendor");
|
const registerVendor = document.getElementById("register-vendor");
|
||||||
const registerModel = document.getElementById("register-model");
|
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 = [
|
let deviceCatalog = [
|
||||||
{
|
{
|
||||||
vendor: "QF",
|
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 buildCapabilityProfile = (type, communicationMode) => {
|
||||||
const dynamic = type === "dynamic_screen_soundbox";
|
const dynamic = type === "dynamic_screen_soundbox";
|
||||||
const selectedModel = getSelectedCatalogModel();
|
const selectedModel = getSelectedCatalogModel();
|
||||||
@ -725,8 +832,7 @@ Rows
|
|||||||
const getSelectedCatalogModel = () => {
|
const getSelectedCatalogModel = () => {
|
||||||
const vendor = registerVendor?.value;
|
const vendor = registerVendor?.value;
|
||||||
const model = registerModel?.value;
|
const model = registerModel?.value;
|
||||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
return getCatalogModel(vendor, model);
|
||||||
return (catalog?.models || []).find((item) => item.model === model) || (catalog?.models || [])[0];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCapabilityPreview = () => {
|
const updateCapabilityPreview = () => {
|
||||||
@ -796,26 +902,29 @@ Rows
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hydrateDeviceCatalog = () => {
|
const hydrateCatalogVendorSelect = (vendorSelect, modelSelect, selectedVendor = "") => {
|
||||||
if (!registerVendor || !registerModel) {
|
if (!vendorSelect || !modelSelect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
registerVendor.innerHTML = "";
|
vendorSelect.innerHTML = "";
|
||||||
deviceCatalog.forEach((item) => {
|
deviceCatalog.forEach((item) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = item.vendor;
|
option.value = item.vendor;
|
||||||
option.textContent = item.label || 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) => {
|
const hydrateCatalogModelSelect = (vendor, modelSelect, selectedModel = "") => {
|
||||||
if (!registerModel) {
|
if (!modelSelect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
const catalog = deviceCatalog.find((item) => item.vendor === vendor) || deviceCatalog[0];
|
||||||
registerModel.innerHTML = "";
|
modelSelect.innerHTML = "";
|
||||||
(catalog?.models || []).forEach((item) => {
|
(catalog?.models || []).forEach((item) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = item.model;
|
option.value = item.model;
|
||||||
@ -826,8 +935,26 @@ Rows
|
|||||||
if (item.mqtt_payload_profile) {
|
if (item.mqtt_payload_profile) {
|
||||||
option.dataset.payloadProfile = 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];
|
const selectedModel = (catalog?.models || [])[0];
|
||||||
if (selectedModel?.communication_mode) {
|
if (selectedModel?.communication_mode) {
|
||||||
document.getElementById("register-communication-mode").value = 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">
|
<tr class="hover:bg-slate-50 transition-colors group">
|
||||||
<td class="px-6 py-row-height">
|
<td class="px-6 py-row-height">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="block font-mono text-primary font-bold">${id || "-"}</span>
|
<span class="block font-mono text-primary font-bold">${serialNumber}</span>
|
||||||
<span class="block font-mono text-[12px] text-slate-500">SN: ${serialNumber}</span>
|
<span class="block font-mono text-[12px] text-slate-500">${id ? `Code: ${escapeHtml(id)}` : "Code: -"}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-row-height">${model}</td>
|
<td class="px-6 py-row-height">${model}</td>
|
||||||
@ -920,6 +1047,10 @@ Rows
|
|||||||
<span class="material-symbols-outlined text-[18px]">visibility</span>
|
<span class="material-symbols-outlined text-[18px]">visibility</span>
|
||||||
Quick Inspect
|
Quick Inspect
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 = () => {
|
const applyFilters = () => {
|
||||||
@ -1370,6 +1513,98 @@ Rows
|
|||||||
registerModal?.classList.add("hidden");
|
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) => {
|
const loadRegisterOutlets = async (merchantId) => {
|
||||||
resetBindingSelects();
|
resetBindingSelects();
|
||||||
if (!merchantId || !registerOutlet) {
|
if (!merchantId || !registerOutlet) {
|
||||||
@ -1496,9 +1731,9 @@ Rows
|
|||||||
Device created
|
Device created
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 grid grid-cols-2 gap-3 text-sm">
|
<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">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><span class="text-slate-500">Binding</span><p class="font-bold">${binding ? "Bound" : "Unassigned"}</p></div>
|
||||||
</div>
|
</div>
|
||||||
${credentialBlock}
|
${credentialBlock}
|
||||||
@ -1565,8 +1800,8 @@ Rows
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const initialQuery = params.get("q") || params.get("focus") || "";
|
const initialQuery = params.get("q") || params.get("focus") || "";
|
||||||
if (initialQuery && searchInput && !searchInput.value) {
|
if (initialQuery && searchInput && !searchInput.value) {
|
||||||
const focusedDevice = rows.find((item) => item.id === initialQuery || item.device_code === initialQuery);
|
const focusedDevice = rows.find((item) => item.id === initialQuery || item.device_code === initialQuery || item.serial_number === initialQuery);
|
||||||
searchInput.value = focusedDevice?.device_code || initialQuery;
|
searchInput.value = focusedDevice?.serial_number || initialQuery;
|
||||||
}
|
}
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
applyFilters();
|
applyFilters();
|
||||||
@ -1638,6 +1873,24 @@ Rows
|
|||||||
registerCancelButton?.addEventListener("click", closeRegisterModal);
|
registerCancelButton?.addEventListener("click", closeRegisterModal);
|
||||||
registerOverlay?.addEventListener("click", closeRegisterModal);
|
registerOverlay?.addEventListener("click", closeRegisterModal);
|
||||||
registerForm?.addEventListener("submit", handleRegisterSubmit);
|
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);
|
registerDeviceType?.addEventListener("change", updateCapabilityPreview);
|
||||||
registerVendor?.addEventListener("change", (event) => hydrateDeviceModels(event.currentTarget.value));
|
registerVendor?.addEventListener("change", (event) => hydrateDeviceModels(event.currentTarget.value));
|
||||||
registerModel?.addEventListener("change", () => {
|
registerModel?.addEventListener("change", () => {
|
||||||
|
|||||||
@ -181,7 +181,7 @@
|
|||||||
<div class="flex items-center gap-4 flex-1">
|
<div class="flex items-center gap-4 flex-1">
|
||||||
<div class="relative w-96">
|
<div class="relative w-96">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||||
<input class="w-full pl-10 pr-4 py-2 bg-surface-container-low border-none rounded-lg text-body-md focus:ring-2 focus:ring-primary/20" placeholder="Search devices, merchants, or transactions..." type="text"/>
|
<input id="device-qr-search" class="w-full pl-10 pr-4 py-2 bg-surface-container-low border-none rounded-lg text-body-md focus:ring-2 focus:ring-primary/20" placeholder="Search devices, merchants, or transactions..." type="text"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -451,6 +451,35 @@
|
|||||||
<span class="material-symbols-outlined">support_agent</span>
|
<span class="material-symbols-outlined">support_agent</span>
|
||||||
</button>
|
</button>
|
||||||
<script>
|
<script>
|
||||||
|
// Simple logic for simulating transaction status changes
|
||||||
|
const qrSearch = document.getElementById('device-qr-search');
|
||||||
|
const filterQrRows = () => {
|
||||||
|
const q = String(qrSearch?.value || '').trim().toLowerCase();
|
||||||
|
const rows = document.querySelectorAll('tbody tr');
|
||||||
|
let visible = 0;
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const match = !q || row.textContent.toLowerCase().includes(q);
|
||||||
|
row.classList.toggle('hidden', !match);
|
||||||
|
if (match) visible += 1;
|
||||||
|
});
|
||||||
|
let emptyRow = document.getElementById('device-qr-search-empty');
|
||||||
|
const tbody = document.querySelector('tbody');
|
||||||
|
if (tbody && !emptyRow) {
|
||||||
|
emptyRow = document.createElement('tr');
|
||||||
|
emptyRow.id = 'device-qr-search-empty';
|
||||||
|
emptyRow.innerHTML = '<td colspan="5" class="px-6 py-8 text-center text-slate-500">No transactions matched the search.</td>';
|
||||||
|
tbody.appendChild(emptyRow);
|
||||||
|
}
|
||||||
|
emptyRow?.classList.toggle('hidden', visible > 0);
|
||||||
|
};
|
||||||
|
qrSearch?.addEventListener('input', filterQrRows);
|
||||||
|
qrSearch?.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && qrSearch.value) {
|
||||||
|
qrSearch.value = '';
|
||||||
|
filterQrRows();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Simple logic for simulating transaction status changes
|
// Simple logic for simulating transaction status changes
|
||||||
const statusText = document.querySelector('.bg-surface-container-low span:last-child');
|
const statusText = document.querySelector('.bg-surface-container-low span:last-child');
|
||||||
const statusIcon = document.querySelector('.bg-surface-container-low span:first-child');
|
const statusIcon = document.querySelector('.bg-surface-container-low span:first-child');
|
||||||
|
|||||||
@ -165,7 +165,7 @@
|
|||||||
<div class="flex items-center gap-6 flex-1">
|
<div class="flex items-center gap-6 flex-1">
|
||||||
<div class="relative w-full max-w-md">
|
<div class="relative w-full max-w-md">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-lg">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-lg">search</span>
|
||||||
<input class="w-full bg-slate-50 border-none rounded-xl pl-10 pr-4 py-2 text-body-md focus:ring-2 focus:ring-primary/20 transition-all" placeholder="Search transactions, devices..." type="text"/>
|
<input id="merchant-dashboard-search" class="w-full bg-slate-50 border-none rounded-xl pl-10 pr-4 py-2 text-body-md focus:ring-2 focus:ring-primary/20 transition-all" placeholder="Search transactions, devices..." type="text"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:flex items-center gap-8">
|
<div class="hidden md:flex items-center gap-8">
|
||||||
<a class="text-primary border-b-2 border-primary font-bold py-6 text-body-md" href="#">Dashboard</a>
|
<a class="text-primary border-b-2 border-primary font-bold py-6 text-body-md" href="#">Dashboard</a>
|
||||||
@ -463,7 +463,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Search Bar Focus Effect
|
// Search Bar Focus Effect
|
||||||
const searchInput = document.querySelector('input[type="text"]');
|
const searchInput = document.getElementById('merchant-dashboard-search');
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('focus', () => {
|
searchInput.addEventListener('focus', () => {
|
||||||
searchInput.parentElement.classList.add('ring-2', 'ring-primary/20');
|
searchInput.parentElement.classList.add('ring-2', 'ring-primary/20');
|
||||||
@ -471,6 +471,15 @@
|
|||||||
searchInput.addEventListener('blur', () => {
|
searchInput.addEventListener('blur', () => {
|
||||||
searchInput.parentElement.classList.remove('ring-2', 'ring-primary/20');
|
searchInput.parentElement.classList.remove('ring-2', 'ring-primary/20');
|
||||||
});
|
});
|
||||||
|
searchInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && searchInput.value) {
|
||||||
|
searchInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && searchInput.value.trim()) {
|
||||||
|
window.location.href = `/ui/merchant-settlement-history?q=${encodeURIComponent(searchInput.value.trim())}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- ui-nav -->
|
<!-- ui-nav -->
|
||||||
|
|||||||
@ -175,7 +175,7 @@
|
|||||||
<div class="flex items-center gap-4 flex-1">
|
<div class="flex items-center gap-4 flex-1">
|
||||||
<div class="relative w-full max-w-md">
|
<div class="relative w-full max-w-md">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||||
<input class="w-full pl-10 pr-4 py-2 bg-surface-container-low border-none rounded-full text-body-md focus:ring-2 focus:ring-primary/20" placeholder="Search merchants, devices, or IDs..." type="text"/>
|
<input id="merchant-detail-search" class="w-full pl-10 pr-4 py-2 bg-surface-container-low border-none rounded-full text-body-md focus:ring-2 focus:ring-primary/20" placeholder="Search merchants, devices, or IDs..." type="text"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
@ -570,6 +570,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep existing button micro-interactions
|
||||||
|
document.getElementById("merchant-detail-search")?.addEventListener("keydown", (event) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
if (event.key === "Escape" && input.value) {
|
||||||
|
input.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && input.value.trim()) {
|
||||||
|
window.location.href = `/ui/merchant-list-management?q=${encodeURIComponent(input.value.trim())}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Keep existing button micro-interactions
|
// Keep existing button micro-interactions
|
||||||
document.querySelectorAll("button").forEach((btn) => {
|
document.querySelectorAll("button").forEach((btn) => {
|
||||||
btn.addEventListener("mousedown", () => {
|
btn.addEventListener("mousedown", () => {
|
||||||
|
|||||||
@ -600,6 +600,10 @@
|
|||||||
rows = Array.isArray(merchantRows) ? merchantRows : [];
|
rows = Array.isArray(merchantRows) ? merchantRows : [];
|
||||||
outlets = Array.isArray(outletRows) ? outletRows : [];
|
outlets = Array.isArray(outletRows) ? outletRows : [];
|
||||||
devices = Array.isArray(deviceRows) ? deviceRows : [];
|
devices = Array.isArray(deviceRows) ? deviceRows : [];
|
||||||
|
const initialQuery = new URLSearchParams(window.location.search).get('q') || '';
|
||||||
|
if (initialQuery && searchInput && !searchInput.value) {
|
||||||
|
searchInput.value = initialQuery;
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[merchant-list] failed loading', error);
|
console.error('[merchant-list] failed loading', error);
|
||||||
|
|||||||
@ -178,7 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input class="bg-slate-100 border-none rounded-full px-4 py-2 text-body-md w-64 focus:ring-2 focus:ring-primary/20" placeholder="Search transactions..." type="text"/>
|
<input id="merchant-settlement-search-input" class="bg-slate-100 border-none rounded-full px-4 py-2 text-body-md w-64 focus:ring-2 focus:ring-primary/20" placeholder="Search batch, status, or account..." type="text"/>
|
||||||
<span class="material-symbols-outlined absolute right-3 top-2 text-slate-500" data-icon="search">search</span>
|
<span class="material-symbols-outlined absolute right-3 top-2 text-slate-500" data-icon="search">search</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@ -608,6 +608,7 @@
|
|||||||
const MerchantSettlementUI = (() => {
|
const MerchantSettlementUI = (() => {
|
||||||
const api = window.MerchantUIAPI;
|
const api = window.MerchantUIAPI;
|
||||||
const rowsEl = document.getElementById('merchant-settlement-rows');
|
const rowsEl = document.getElementById('merchant-settlement-rows');
|
||||||
|
const searchInput = document.getElementById('merchant-settlement-search-input');
|
||||||
const statusFilter = document.getElementById('merchant-settlement-status-filter');
|
const statusFilter = document.getElementById('merchant-settlement-status-filter');
|
||||||
const summaryEl = document.getElementById('merchant-settlement-summary');
|
const summaryEl = document.getElementById('merchant-settlement-summary');
|
||||||
const downloadBtn = document.getElementById('drawer-download-report');
|
const downloadBtn = document.getElementById('drawer-download-report');
|
||||||
@ -628,15 +629,32 @@
|
|||||||
return 'bg-warning/10 text-warning';
|
return 'bg-warning/10 text-warning';
|
||||||
};
|
};
|
||||||
const statusLabel = (status) => status === 'created' ? 'PENDING' : String(status || '-').toUpperCase();
|
const statusLabel = (status) => status === 'created' ? 'PENDING' : String(status || '-').toUpperCase();
|
||||||
|
let loadedBatches = [];
|
||||||
|
let loadedMerchant = null;
|
||||||
|
|
||||||
const renderRows = (batches, merchant) => {
|
const renderRows = (batches, merchant) => {
|
||||||
if (!rowsEl) return;
|
if (!rowsEl) return;
|
||||||
if (!batches.length) {
|
const query = String(searchInput?.value || '').trim().toLowerCase();
|
||||||
rowsEl.innerHTML = '<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">No settlement batches available.</td></tr>';
|
const filtered = (batches || []).filter((batch) => {
|
||||||
if (summaryEl) summaryEl.textContent = 'Showing 0 disbursements';
|
const haystack = [
|
||||||
|
batch.id,
|
||||||
|
batch.batch_code,
|
||||||
|
batch.status,
|
||||||
|
batch.failure_reason,
|
||||||
|
merchant?.settlement_account_reference,
|
||||||
|
batch.metadata_json?.paid_reference,
|
||||||
|
batch.metadata_json?.paid_note,
|
||||||
|
batch.gross_amount,
|
||||||
|
batch.net_payable_amount
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return !query || haystack.includes(query);
|
||||||
|
});
|
||||||
|
if (!filtered.length) {
|
||||||
|
rowsEl.innerHTML = `<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">${query ? 'No disbursements matched the search.' : 'No settlement batches available.'}</td></tr>`;
|
||||||
|
if (summaryEl) summaryEl.textContent = query ? `No results for "${searchInput.value.trim()}"` : 'Showing 0 disbursements';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rowsEl.innerHTML = batches.map((batch) => `
|
rowsEl.innerHTML = filtered.map((batch) => `
|
||||||
<tr class="hover:bg-slate-50 transition-colors h-row-height group cursor-pointer" data-batch-id="${batch.id}">
|
<tr class="hover:bg-slate-50 transition-colors h-row-height group cursor-pointer" data-batch-id="${batch.id}">
|
||||||
<td class="px-6 font-label-md text-label-md font-semibold text-primary">${batch.batch_code}</td>
|
<td class="px-6 font-label-md text-label-md font-semibold text-primary">${batch.batch_code}</td>
|
||||||
<td class="px-6 font-body-md text-body-md text-on-surface">${dt(batch.paid_at || batch.created_at)}</td>
|
<td class="px-6 font-body-md text-body-md text-on-surface">${dt(batch.paid_at || batch.created_at)}</td>
|
||||||
@ -655,7 +673,7 @@
|
|||||||
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
||||||
row.addEventListener('click', () => openDrawer(row.dataset.batchId, merchant));
|
row.addEventListener('click', () => openDrawer(row.dataset.batchId, merchant));
|
||||||
});
|
});
|
||||||
if (summaryEl) summaryEl.textContent = `Showing ${batches.length} disbursement(s)`;
|
if (summaryEl) summaryEl.textContent = `Showing ${filtered.length} of ${batches.length} disbursement(s)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEvents = (events) => {
|
const renderEvents = (events) => {
|
||||||
@ -729,6 +747,10 @@
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
api.requireSession();
|
api.requireSession();
|
||||||
|
const initialQuery = new URLSearchParams(window.location.search).get('q') || '';
|
||||||
|
if (initialQuery && searchInput && !searchInput.value) {
|
||||||
|
searchInput.value = initialQuery;
|
||||||
|
}
|
||||||
const [profile, summary, batches] = await Promise.all([
|
const [profile, summary, batches] = await Promise.all([
|
||||||
api.getProfile(),
|
api.getProfile(),
|
||||||
api.getSettlementSummary(),
|
api.getSettlementSummary(),
|
||||||
@ -746,10 +768,19 @@
|
|||||||
setText('merchant-adjustment-amount', `Adj ${money(summary.adjustment_amount || 0)}`);
|
setText('merchant-adjustment-amount', `Adj ${money(summary.adjustment_amount || 0)}`);
|
||||||
setText('merchant-next-payout-amount', money(summary.pending_amount));
|
setText('merchant-next-payout-amount', money(summary.pending_amount));
|
||||||
setText('merchant-next-payout-date', Number(summary.created_batches || 0) > 0 ? 'On next payout run' : '-');
|
setText('merchant-next-payout-date', Number(summary.created_batches || 0) > 0 ? 'On next payout run' : '-');
|
||||||
renderRows(batches || [], merchant);
|
loadedBatches = batches || [];
|
||||||
|
loadedMerchant = merchant;
|
||||||
|
renderRows(loadedBatches, loadedMerchant);
|
||||||
};
|
};
|
||||||
|
|
||||||
statusFilter?.addEventListener('change', load);
|
statusFilter?.addEventListener('change', load);
|
||||||
|
searchInput?.addEventListener('input', () => renderRows(loadedBatches, loadedMerchant));
|
||||||
|
searchInput?.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && searchInput.value) {
|
||||||
|
searchInput.value = '';
|
||||||
|
renderRows(loadedBatches, loadedMerchant);
|
||||||
|
}
|
||||||
|
});
|
||||||
downloadBtn?.addEventListener('click', downloadActiveCsv);
|
downloadBtn?.addEventListener('click', downloadActiveCsv);
|
||||||
logoutBtn?.addEventListener('click', () => {
|
logoutBtn?.addEventListener('click', () => {
|
||||||
api.clearSession();
|
api.clearSession();
|
||||||
|
|||||||
@ -167,7 +167,7 @@
|
|||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="relative w-64 group">
|
<div class="relative w-64 group">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors" data-icon="search">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors" data-icon="search">search</span>
|
||||||
<input class="w-full bg-slate-50 border-none rounded-lg pl-10 pr-4 py-2 text-body-md focus:ring-2 focus:ring-primary/20 focus:bg-white transition-all" placeholder="Search batch or merchant..." type="text"/>
|
<input id="settlement-search-input" class="w-full bg-slate-50 border-none rounded-lg pl-10 pr-4 py-2 text-body-md focus:ring-2 focus:ring-primary/20 focus:bg-white transition-all" placeholder="Search batch or merchant..." type="text"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="hidden md:flex gap-6 items-center">
|
<nav class="hidden md:flex gap-6 items-center">
|
||||||
<a class="font-body-md font-bold text-primary border-b-2 border-primary h-[72px] flex items-center" href="/ui/admin-dashboard-overview">Dashboard</a>
|
<a class="font-body-md font-bold text-primary border-b-2 border-primary h-[72px] flex items-center" href="/ui/admin-dashboard-overview">Dashboard</a>
|
||||||
@ -504,6 +504,7 @@
|
|||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
const rowsEl = document.getElementById('settlement-batch-rows');
|
const rowsEl = document.getElementById('settlement-batch-rows');
|
||||||
|
const searchInput = document.getElementById('settlement-search-input');
|
||||||
const generateBtn = document.getElementById('generate-settlement-batch');
|
const generateBtn = document.getElementById('generate-settlement-batch');
|
||||||
const statusFilter = document.getElementById('settlement-status-filter');
|
const statusFilter = document.getElementById('settlement-status-filter');
|
||||||
const summaryEl = document.getElementById('settlement-pagination-summary');
|
const summaryEl = document.getElementById('settlement-pagination-summary');
|
||||||
@ -535,6 +536,7 @@
|
|||||||
const reprocessBtn = document.getElementById('reprocess-settlement-batch');
|
const reprocessBtn = document.getElementById('reprocess-settlement-batch');
|
||||||
const settlementEventsEl = document.getElementById('drawerSettlementEvents');
|
const settlementEventsEl = document.getElementById('drawerSettlementEvents');
|
||||||
let batches = [];
|
let batches = [];
|
||||||
|
let visibleBatches = [];
|
||||||
let activeBatchId = null;
|
let activeBatchId = null;
|
||||||
let activeBatchCode = '';
|
let activeBatchCode = '';
|
||||||
let adminProfile = null;
|
let adminProfile = null;
|
||||||
@ -586,11 +588,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderKpis = () => {
|
const renderKpis = () => {
|
||||||
const created = batches.filter((batch) => batch.status === 'created');
|
const source = searchInput?.value ? visibleBatches : batches;
|
||||||
const paid = batches.filter((batch) => batch.status === 'paid');
|
const created = source.filter((batch) => batch.status === 'created');
|
||||||
|
const paid = source.filter((batch) => batch.status === 'paid');
|
||||||
const pendingAmount = created.reduce((sum, batch) => sum + Number(batch.net_payable_amount || 0), 0);
|
const pendingAmount = created.reduce((sum, batch) => sum + Number(batch.net_payable_amount || 0), 0);
|
||||||
const totalFees = batches.reduce((sum, batch) => sum + Number(batch.platform_fee_amount || 0), 0);
|
const totalFees = source.reduce((sum, batch) => sum + Number(batch.platform_fee_amount || 0), 0);
|
||||||
const totalAdjustments = batches.reduce((sum, batch) => sum + Number(batch.metadata_json?.total_adjustment_amount || 0), 0);
|
const totalAdjustments = source.reduce((sum, batch) => sum + Number(batch.metadata_json?.total_adjustment_amount || 0), 0);
|
||||||
setText('kpi-pending-payouts', money(pendingAmount));
|
setText('kpi-pending-payouts', money(pendingAmount));
|
||||||
setText('kpi-created-batches', String(created.length));
|
setText('kpi-created-batches', String(created.length));
|
||||||
setText('kpi-paid-batches', String(paid.length));
|
setText('kpi-paid-batches', String(paid.length));
|
||||||
@ -600,14 +603,29 @@
|
|||||||
|
|
||||||
const renderRows = () => {
|
const renderRows = () => {
|
||||||
if (!rowsEl) return;
|
if (!rowsEl) return;
|
||||||
if (!batches.length) {
|
const query = String(searchInput?.value || '').trim().toLowerCase();
|
||||||
rowsEl.innerHTML = '<tr><td colspan="8" class="px-6 py-10 text-center text-slate-500">No settlement batches yet.</td></tr>';
|
visibleBatches = batches.filter((batch) => {
|
||||||
summaryEl.textContent = 'Showing 0 batches';
|
const haystack = [
|
||||||
|
batch.id,
|
||||||
|
batch.batch_code,
|
||||||
|
batch.merchant_id,
|
||||||
|
batch.status,
|
||||||
|
batch.failure_reason,
|
||||||
|
batch.metadata_json?.paid_reference,
|
||||||
|
batch.metadata_json?.paid_note,
|
||||||
|
batch.gross_amount,
|
||||||
|
batch.net_payable_amount
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return !query || haystack.includes(query);
|
||||||
|
});
|
||||||
|
if (!visibleBatches.length) {
|
||||||
|
rowsEl.innerHTML = `<tr><td colspan="8" class="px-6 py-10 text-center text-slate-500">${query ? 'No settlement batches matched the search.' : 'No settlement batches yet.'}</td></tr>`;
|
||||||
|
summaryEl.textContent = query ? `No results for "${searchInput.value.trim()}"` : 'Showing 0 batches';
|
||||||
renderKpis();
|
renderKpis();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rowsEl.innerHTML = batches.map((batch) => `
|
rowsEl.innerHTML = visibleBatches.map((batch) => `
|
||||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer" data-batch-id="${batch.id}">
|
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer" data-batch-id="${batch.id}">
|
||||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">${batch.batch_code}</td>
|
<td class="px-6 font-body-md font-bold text-primary tabular-nums">${batch.batch_code}</td>
|
||||||
<td class="px-6 font-body-md text-on-surface-variant">${dt(batch.cutoff_at)}</td>
|
<td class="px-6 font-body-md text-on-surface-variant">${dt(batch.cutoff_at)}</td>
|
||||||
@ -629,7 +647,7 @@
|
|||||||
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
||||||
row.addEventListener('click', () => openDrawer(row.dataset.batchId));
|
row.addEventListener('click', () => openDrawer(row.dataset.batchId));
|
||||||
});
|
});
|
||||||
summaryEl.textContent = `Showing ${batches.length} batches`;
|
summaryEl.textContent = `Showing ${visibleBatches.length} of ${batches.length} batches`;
|
||||||
renderKpis();
|
renderKpis();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -743,6 +761,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
statusFilter?.addEventListener('change', loadBatches);
|
statusFilter?.addEventListener('change', loadBatches);
|
||||||
|
searchInput?.addEventListener('input', renderRows);
|
||||||
|
searchInput?.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && searchInput.value) {
|
||||||
|
searchInput.value = '';
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
});
|
||||||
markPaidBtn?.addEventListener('click', async () => {
|
markPaidBtn?.addEventListener('click', async () => {
|
||||||
if (!activeBatchId) return;
|
if (!activeBatchId) return;
|
||||||
if (paidForm && !paidForm.reportValidity()) return;
|
if (paidForm && !paidForm.reportValidity()) return;
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<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=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" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Inter, Arial, sans-serif; }
|
body { font-family: Inter, Arial, sans-serif; overflow-x: hidden; }
|
||||||
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
|
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
|
||||||
.mono { font-family: "JetBrains Mono", monospace; }
|
.mono { font-family: "JetBrains Mono", monospace; }
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
@ -60,19 +60,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 class="mt-1 text-2xl font-extrabold tracking-normal text-slate-950">Soundbox Monitoring</h2>
|
<h2 class="mt-1 text-2xl font-extrabold tracking-normal text-slate-950">Soundbox Monitoring</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center xl:w-auto">
|
||||||
<div class="relative min-w-64 flex-1 xl:w-96 xl:flex-none">
|
<div class="relative w-full min-w-0 sm:flex-[1_1_240px] xl:w-96 xl:flex-none">
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||||
<input id="search-input" class="w-full rounded-lg border-slate-200 bg-slate-50 py-2 pl-10 pr-3 text-sm focus:border-blue-600 focus:ring-blue-600" placeholder="Search code, serial, model, merchant" type="search" />
|
<input id="search-input" class="w-full rounded-lg border-slate-200 bg-slate-50 py-2 pl-10 pr-3 text-sm focus:border-blue-600 focus:ring-blue-600" placeholder="Search code, serial, model, merchant" type="search" />
|
||||||
</div>
|
</div>
|
||||||
<select id="status-filter" class="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600">
|
<select id="status-filter" class="w-full min-w-0 rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600 sm:flex-[1_1_150px] xl:w-auto xl:flex-none">
|
||||||
<option value="">All status</option>
|
<option value="">All status</option>
|
||||||
<option value="online">Online</option>
|
<option value="online">Online</option>
|
||||||
|
<option value="warning">Stale/Degraded</option>
|
||||||
<option value="degraded">Degraded</option>
|
<option value="degraded">Degraded</option>
|
||||||
<option value="stale">Stale</option>
|
<option value="stale">Stale</option>
|
||||||
<option value="offline">Offline</option>
|
<option value="offline">Offline</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="refresh-button" class="inline-flex items-center gap-2 rounded-lg bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800">
|
<button id="refresh-button" class="inline-flex w-full flex-none items-center justify-center gap-2 rounded-lg bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800 sm:w-auto">
|
||||||
<span class="material-symbols-outlined text-[20px]">sync</span>
|
<span class="material-symbols-outlined text-[20px]">sync</span>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@ -85,7 +86,7 @@
|
|||||||
<div id="error-banner" class="mb-4 hidden rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"></div>
|
<div id="error-banner" class="mb-4 hidden rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"></div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
<article id="kpi-total-card" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Total Soundbox</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Total Soundbox</p>
|
||||||
<span class="material-symbols-outlined text-blue-700">speaker_group</span>
|
<span class="material-symbols-outlined text-blue-700">speaker_group</span>
|
||||||
@ -93,7 +94,7 @@
|
|||||||
<p id="kpi-total" class="mt-3 text-3xl font-extrabold">0</p>
|
<p id="kpi-total" class="mt-3 text-3xl font-extrabold">0</p>
|
||||||
<p class="mt-1 text-sm text-slate-500">registered devices</p>
|
<p class="mt-1 text-sm text-slate-500">registered devices</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
<article id="kpi-online-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-emerald-200 hover:bg-emerald-50/30">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Online</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Online</p>
|
||||||
<span class="material-symbols-outlined text-emerald-600">check_circle</span>
|
<span class="material-symbols-outlined text-emerald-600">check_circle</span>
|
||||||
@ -101,21 +102,21 @@
|
|||||||
<p id="kpi-online" class="mt-3 text-3xl font-extrabold text-emerald-700">0</p>
|
<p id="kpi-online" class="mt-3 text-3xl font-extrabold text-emerald-700">0</p>
|
||||||
<p id="kpi-online-rate" class="mt-1 text-sm text-slate-500">0% online rate</p>
|
<p id="kpi-online-rate" class="mt-1 text-sm text-slate-500">0% online rate</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
<article id="kpi-warning-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-amber-200 hover:bg-amber-50/30">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Stale/Degraded</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Stale/Degraded</p>
|
||||||
<span class="material-symbols-outlined text-amber-600">running_with_errors</span>
|
<span class="material-symbols-outlined text-amber-600">running_with_errors</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="kpi-warning" class="mt-3 text-3xl font-extrabold text-amber-700">0</p>
|
<p id="kpi-warning" class="mt-3 text-3xl font-extrabold text-amber-700">0</p>
|
||||||
<p class="mt-1 text-sm text-slate-500">needs operator attention</p>
|
<p id="kpi-warning-detail" class="mt-1 text-sm text-slate-500">0 stale · 0 degraded</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
<article id="kpi-offline-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:bg-slate-100/60">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Offline</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Offline</p>
|
||||||
<span class="material-symbols-outlined text-slate-500">cloud_off</span>
|
<span class="material-symbols-outlined text-slate-500">cloud_off</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="kpi-offline" class="mt-3 text-3xl font-extrabold text-slate-700">0</p>
|
<p id="kpi-offline" class="mt-3 text-3xl font-extrabold text-slate-700">0</p>
|
||||||
<p class="mt-1 text-sm text-slate-500">no recent heartbeat</p>
|
<p id="kpi-offline-detail" class="mt-1 text-sm text-slate-500">no recent heartbeat</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@ -123,7 +124,7 @@
|
|||||||
<span id="mqtt-icon" class="material-symbols-outlined text-slate-500">hub</span>
|
<span id="mqtt-icon" class="material-symbols-outlined text-slate-500">hub</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="kpi-mqtt" class="mt-3 text-xl font-extrabold">Checking</p>
|
<p id="kpi-mqtt" class="mt-3 text-xl font-extrabold">Checking</p>
|
||||||
<p id="kpi-mqtt-detail" class="mt-1 text-sm text-slate-500">broker state</p>
|
<p id="kpi-mqtt-detail" class="mt-1 break-all text-sm text-slate-500">broker state</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -148,11 +149,10 @@
|
|||||||
<th class="px-5 py-3">Mode</th>
|
<th class="px-5 py-3">Mode</th>
|
||||||
<th class="px-5 py-3">Health</th>
|
<th class="px-5 py-3">Health</th>
|
||||||
<th class="px-5 py-3 text-right">Last Seen</th>
|
<th class="px-5 py-3 text-right">Last Seen</th>
|
||||||
<th class="px-5 py-3 text-right">Action</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="device-table" class="divide-y divide-slate-100">
|
<tbody id="device-table" class="divide-y divide-slate-100">
|
||||||
<tr><td colspan="6" class="px-5 py-8 text-center text-slate-500">Loading devices...</td></tr>
|
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">Loading devices...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -199,6 +199,10 @@
|
|||||||
<span class="material-symbols-outlined text-[20px]">restart_alt</span>
|
<span class="material-symbols-outlined text-[20px]">restart_alt</span>
|
||||||
Reboot Device
|
Reboot Device
|
||||||
</button>
|
</button>
|
||||||
|
<button id="send-poweroff-command" class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-extrabold text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50" disabled>
|
||||||
|
<span class="material-symbols-outlined text-[20px]">power_settings_new</span>
|
||||||
|
Power Off Device
|
||||||
|
</button>
|
||||||
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
|
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -230,6 +234,21 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const normalize = (value) => String(value || "").toLowerCase().trim();
|
const normalize = (value) => String(value || "").toLowerCase().trim();
|
||||||
const fmt = window.AdminUIAPI?.formatDateTime || ((value) => value || "-");
|
const fmt = window.AdminUIAPI?.formatDateTime || ((value) => value || "-");
|
||||||
|
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
}[char]));
|
||||||
|
|
||||||
|
const reasonLabels = {
|
||||||
|
no_heartbeat: "No heartbeat",
|
||||||
|
offline_threshold_exceeded: "Offline threshold",
|
||||||
|
stale_threshold_exceeded: "Stale heartbeat",
|
||||||
|
low_signal: "Low signal",
|
||||||
|
low_battery: "Low battery"
|
||||||
|
};
|
||||||
|
|
||||||
function lastSeen(value) {
|
function lastSeen(value) {
|
||||||
if (!value) return "No heartbeat";
|
if (!value) return "No heartbeat";
|
||||||
@ -274,6 +293,23 @@
|
|||||||
return device.latest_heartbeat ? "Tracked" : "Unknown";
|
return device.latest_heartbeat ? "Tracked" : "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function healthTone(score) {
|
||||||
|
if (typeof score !== "number") return "bg-slate-200";
|
||||||
|
if (score >= 85) return "bg-emerald-500";
|
||||||
|
if (score >= 60) return "bg-amber-500";
|
||||||
|
return "bg-red-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthReasons(device) {
|
||||||
|
const reasons = device.health_summary?.reasons;
|
||||||
|
if (!Array.isArray(reasons) || !reasons.length) return "No active warning";
|
||||||
|
return reasons.map((item) => reasonLabels[item] || item).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricValue(value, suffix = "") {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? `${value}${suffix}` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
function merchantName(device) {
|
function merchantName(device) {
|
||||||
const id = device.binding_summary?.merchant_id || device.active_binding?.merchant_id;
|
const id = device.binding_summary?.merchant_id || device.active_binding?.merchant_id;
|
||||||
return state.merchants.get(id) || "Unassigned";
|
return state.merchants.get(id) || "Unassigned";
|
||||||
@ -292,7 +328,9 @@
|
|||||||
merchantName(device)
|
merchantName(device)
|
||||||
].map(normalize).join(" ");
|
].map(normalize).join(" ");
|
||||||
const matchesQuery = !query || text.includes(query);
|
const matchesQuery = !query || text.includes(query);
|
||||||
const matchesStatus = !status || normalize(device.derived_status) === status;
|
const deviceStatus = normalize(device.derived_status);
|
||||||
|
const matchesStatus = !status ||
|
||||||
|
(status === "warning" ? ["stale", "degraded"].includes(deviceStatus) : deviceStatus === status);
|
||||||
return matchesQuery && matchesStatus;
|
return matchesQuery && matchesStatus;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -300,13 +338,17 @@
|
|||||||
function renderKpis() {
|
function renderKpis() {
|
||||||
const total = state.devices.length;
|
const total = state.devices.length;
|
||||||
const online = state.devices.filter((item) => normalize(item.derived_status) === "online").length;
|
const online = state.devices.filter((item) => normalize(item.derived_status) === "online").length;
|
||||||
const warning = state.devices.filter((item) => ["stale", "degraded"].includes(normalize(item.derived_status))).length;
|
const stale = state.devices.filter((item) => normalize(item.derived_status) === "stale").length;
|
||||||
|
const degraded = state.devices.filter((item) => normalize(item.derived_status) === "degraded").length;
|
||||||
|
const warning = stale + degraded;
|
||||||
const offline = state.devices.filter((item) => !["online", "stale", "degraded"].includes(normalize(item.derived_status))).length;
|
const offline = state.devices.filter((item) => !["online", "stale", "degraded"].includes(normalize(item.derived_status))).length;
|
||||||
$("kpi-total").textContent = total;
|
$("kpi-total").textContent = total;
|
||||||
$("kpi-online").textContent = online;
|
$("kpi-online").textContent = online;
|
||||||
$("kpi-warning").textContent = warning;
|
$("kpi-warning").textContent = warning;
|
||||||
$("kpi-offline").textContent = offline;
|
$("kpi-offline").textContent = offline;
|
||||||
$("kpi-online-rate").textContent = `${total ? Math.round((online / total) * 100) : 0}% online rate`;
|
$("kpi-online-rate").textContent = `${total ? Math.round((online / total) * 100) : 0}% online rate`;
|
||||||
|
$("kpi-warning-detail").textContent = `${stale} stale · ${degraded} degraded`;
|
||||||
|
$("kpi-offline-detail").textContent = total ? `${Math.round((offline / total) * 100)}% of fleet` : "no recent heartbeat";
|
||||||
|
|
||||||
const publisher = state.mqtt?.publisher || {};
|
const publisher = state.mqtt?.publisher || {};
|
||||||
const subscriber = state.mqtt?.subscriber || {};
|
const subscriber = state.mqtt?.subscriber || {};
|
||||||
@ -322,42 +364,49 @@
|
|||||||
$("fleet-subtitle").textContent = `${rows.length} visible of ${state.devices.length} registered soundbox units`;
|
$("fleet-subtitle").textContent = `${rows.length} visible of ${state.devices.length} registered soundbox units`;
|
||||||
const table = $("device-table");
|
const table = $("device-table");
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
table.innerHTML = '<tr><td colspan="6" class="px-5 py-8 text-center text-slate-500">No soundbox matched the current filters.</td></tr>';
|
table.innerHTML = '<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No soundbox matched the current filters.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.innerHTML = rows.slice(0, 50).map((device) => {
|
table.innerHTML = rows.slice(0, 50).map((device) => {
|
||||||
const status = statusMeta(device.derived_status);
|
const status = statusMeta(device.derived_status);
|
||||||
const mode = modeMeta(device.communication_mode);
|
const mode = modeMeta(device.communication_mode);
|
||||||
|
const score = device.health_summary?.score;
|
||||||
const code = device.device_code || device.id || "-";
|
const code = device.device_code || device.id || "-";
|
||||||
|
const heartbeat = device.latest_heartbeat || {};
|
||||||
|
const signal = heartbeat.network_strength;
|
||||||
|
const battery = heartbeat.battery_level;
|
||||||
|
const scoreWidth = typeof score === "number" ? Math.max(4, Math.min(100, score)) : 0;
|
||||||
const detailUrl = `/ui/device-technical-detail?device_id=${encodeURIComponent(device.id)}`;
|
const detailUrl = `/ui/device-technical-detail?device_id=${encodeURIComponent(device.id)}`;
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-5 py-4">
|
<td class="px-5 py-4">
|
||||||
<div class="font-bold text-slate-950">${code}</div>
|
<a class="font-bold text-slate-950 hover:text-blue-700" href="${detailUrl}">${esc(code)}</a>
|
||||||
<div class="mono mt-1 text-xs text-slate-500">${device.serial_number || device.id || "-"}</div>
|
<div class="mono mt-1 text-xs text-slate-500">${esc(device.serial_number || device.id || "-")}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-4">
|
<td class="px-5 py-4">
|
||||||
<div class="font-semibold">${merchantName(device)}</div>
|
<div class="font-semibold">${esc(merchantName(device))}</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">${device.model || "Unknown model"}</div>
|
<div class="mt-1 text-xs text-slate-500">${esc(device.model || "Unknown model")}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-4">
|
<td class="px-5 py-4">
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700">
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700">
|
||||||
<span class="material-symbols-outlined text-[16px]">${mode.icon}</span>${mode.label}
|
<span class="material-symbols-outlined text-[16px]">${esc(mode.icon)}</span>${esc(mode.label)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-4">
|
<td class="px-5 py-4">
|
||||||
<div class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-bold ${status.badge}">
|
<div class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-bold ${status.badge}">
|
||||||
<span class="h-1.5 w-1.5 rounded-full ${status.dot}"></span>${status.label}
|
<span class="h-1.5 w-1.5 rounded-full ${status.dot}"></span>${esc(status.label)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">Health ${healthLabel(device)}</div>
|
<div class="mt-2 flex items-center gap-2">
|
||||||
</td>
|
<div class="h-1.5 w-20 overflow-hidden rounded-full bg-slate-200">
|
||||||
<td class="px-5 py-4 text-right text-slate-600">${lastSeen(deviceLastSeen(device))}</td>
|
<div class="h-full ${healthTone(score)}" style="width:${scoreWidth}%"></div>
|
||||||
<td class="px-5 py-4 text-right">
|
</div>
|
||||||
<a class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-50" href="${detailUrl}">
|
<span class="text-xs font-bold text-slate-600">${esc(healthLabel(device))}</span>
|
||||||
Detail <span class="material-symbols-outlined text-[16px]">open_in_new</span>
|
</div>
|
||||||
</a>
|
<div class="mt-1 max-w-[180px] truncate text-xs text-slate-500" title="${esc(healthReasons(device))}">${esc(healthReasons(device))}</div>
|
||||||
|
<div class="mt-1 text-[11px] font-semibold text-slate-500">Signal ${esc(metricValue(signal, typeof signal === "number" && signal < 0 ? " dBm" : ""))} · Battery ${esc(metricValue(battery, "%"))}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-5 py-4 text-right text-slate-600">${esc(lastSeen(deviceLastSeen(device)))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@ -379,7 +428,8 @@
|
|||||||
function renderCommandDevices() {
|
function renderCommandDevices() {
|
||||||
const select = $("command-device-select");
|
const select = $("command-device-select");
|
||||||
const rebootButton = $("send-reboot-command");
|
const rebootButton = $("send-reboot-command");
|
||||||
if (!select || !rebootButton) {
|
const poweroffButton = $("send-poweroff-command");
|
||||||
|
if (!select || !rebootButton || !poweroffButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,6 +445,7 @@
|
|||||||
select.value = current;
|
select.value = current;
|
||||||
}
|
}
|
||||||
rebootButton.disabled = !select.value;
|
rebootButton.disabled = !select.value;
|
||||||
|
poweroffButton.disabled = !select.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMqtt() {
|
function renderMqtt() {
|
||||||
@ -410,11 +461,11 @@
|
|||||||
return `
|
return `
|
||||||
<div class="rounded-lg px-2 py-3 hover:bg-slate-50">
|
<div class="rounded-lg px-2 py-3 hover:bg-slate-50">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-bold uppercase ${color}">${message.direction || "trace"}</span>
|
<span class="rounded-full px-2 py-0.5 text-[11px] font-bold uppercase ${color}">${esc(message.direction || "trace")}</span>
|
||||||
<span class="text-xs text-slate-500">${lastSeen(message.created_at)}</span>
|
<span class="text-xs text-slate-500">${esc(lastSeen(message.created_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mono mt-2 break-all text-xs font-semibold text-slate-700">${message.topic || "-"}</div>
|
<div class="mono mt-2 break-all text-xs font-semibold text-slate-700">${esc(message.topic || "-")}</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">${message.message_type || "message"} · ${message.publish_status || "recorded"}</div>
|
<div class="mt-1 text-xs text-slate-500">${esc(message.message_type || "message")} · ${esc(message.publish_status || "recorded")}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@ -456,8 +507,12 @@
|
|||||||
model: "QF100 Static",
|
model: "QF100 Static",
|
||||||
communication_mode: "mqtt",
|
communication_mode: "mqtt",
|
||||||
derived_status: "online",
|
derived_status: "online",
|
||||||
latest_heartbeat: new Date(now - 90 * 1000).toISOString(),
|
latest_heartbeat: {
|
||||||
health_summary: { score: 98 },
|
received_at: new Date(now - 45 * 1000).toISOString(),
|
||||||
|
network_strength: 72,
|
||||||
|
battery_level: 86
|
||||||
|
},
|
||||||
|
health_summary: { score: 98, reasons: [], age_seconds: 45 },
|
||||||
binding_summary: { merchant_id: "merchant_qf100" }
|
binding_summary: { merchant_id: "merchant_qf100" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -468,8 +523,12 @@
|
|||||||
model: "QF100 Dynamic",
|
model: "QF100 Dynamic",
|
||||||
communication_mode: "mqtt",
|
communication_mode: "mqtt",
|
||||||
derived_status: "stale",
|
derived_status: "stale",
|
||||||
latest_heartbeat: new Date(now - 42 * 60 * 1000).toISOString(),
|
latest_heartbeat: {
|
||||||
health_summary: { score: 64 },
|
received_at: new Date(now - 42 * 60 * 1000).toISOString(),
|
||||||
|
network_strength: 54,
|
||||||
|
battery_level: 61
|
||||||
|
},
|
||||||
|
health_summary: { score: 65, reasons: ["stale_threshold_exceeded"], age_seconds: 2520 },
|
||||||
binding_summary: { merchant_id: "merchant_mbiz" }
|
binding_summary: { merchant_id: "merchant_mbiz" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -480,8 +539,12 @@
|
|||||||
model: "Soundbox V2",
|
model: "Soundbox V2",
|
||||||
communication_mode: "api",
|
communication_mode: "api",
|
||||||
derived_status: "degraded",
|
derived_status: "degraded",
|
||||||
latest_heartbeat: new Date(now - 12 * 60 * 1000).toISOString(),
|
latest_heartbeat: {
|
||||||
health_summary: { score: 72 },
|
received_at: new Date(now - 54 * 1000).toISOString(),
|
||||||
|
network_strength: 28,
|
||||||
|
battery_level: 17
|
||||||
|
},
|
||||||
|
health_summary: { score: 85, reasons: ["low_signal", "low_battery"], age_seconds: 54 },
|
||||||
binding_summary: { merchant_id: "merchant_demo" }
|
binding_summary: { merchant_id: "merchant_demo" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -493,7 +556,7 @@
|
|||||||
communication_mode: "static",
|
communication_mode: "static",
|
||||||
derived_status: "offline",
|
derived_status: "offline",
|
||||||
latest_heartbeat: null,
|
latest_heartbeat: null,
|
||||||
health_summary: { score: 0 },
|
health_summary: { score: 0, reasons: ["no_heartbeat"], age_seconds: null },
|
||||||
binding_summary: null
|
binding_summary: null
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -569,9 +632,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendRebootCommand() {
|
async function sendDeviceCommand(commandName, buttonId, actionLabel) {
|
||||||
const select = $("command-device-select");
|
const select = $("command-device-select");
|
||||||
const button = $("send-reboot-command");
|
const button = $(buttonId);
|
||||||
const status = $("command-status");
|
const status = $("command-status");
|
||||||
const deviceId = select?.value || "";
|
const deviceId = select?.value || "";
|
||||||
const device = state.devices.find((item) => item.id === deviceId);
|
const device = state.devices.find((item) => item.id === deviceId);
|
||||||
@ -581,19 +644,19 @@
|
|||||||
|
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.classList.add("opacity-60");
|
button.classList.add("opacity-60");
|
||||||
status.textContent = `Sending reboot to ${device?.device_code || device?.serial_number || deviceId}...`;
|
status.textContent = `Sending ${actionLabel.toLowerCase()} to ${device?.device_code || device?.serial_number || deviceId}...`;
|
||||||
status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500";
|
status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500";
|
||||||
try {
|
try {
|
||||||
const result = await api.createDeviceCommand(deviceId, {
|
const result = await api.createDeviceCommand(deviceId, {
|
||||||
command: "device.reboot",
|
command: commandName,
|
||||||
payload: { requested_from: "soundbox_ops" }
|
payload: { requested_from: "soundbox_ops" }
|
||||||
});
|
});
|
||||||
const topic = result?.result_payload?.topic || "-";
|
const topic = result?.result_payload?.topic || "-";
|
||||||
status.textContent = `Reboot command ${result.status || "queued"} · ${topic}`;
|
status.textContent = `${actionLabel} command ${result.status || "queued"} · ${topic}`;
|
||||||
status.className = "mt-2 min-h-5 text-xs font-semibold text-emerald-700";
|
status.className = "mt-2 min-h-5 text-xs font-semibold text-emerald-700";
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error?.message || "Unable to send reboot command.";
|
status.textContent = error?.message || `Unable to send ${actionLabel.toLowerCase()} command.`;
|
||||||
status.className = "mt-2 min-h-5 text-xs font-semibold text-red-700";
|
status.className = "mt-2 min-h-5 text-xs font-semibold text-red-700";
|
||||||
} finally {
|
} finally {
|
||||||
button.classList.remove("opacity-60");
|
button.classList.remove("opacity-60");
|
||||||
@ -601,14 +664,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyStatusFilter(value) {
|
||||||
|
$("status-filter").value = value;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
$("refresh-button").addEventListener("click", refresh);
|
$("refresh-button").addEventListener("click", refresh);
|
||||||
$("search-input").addEventListener("input", renderTable);
|
$("search-input").addEventListener("input", renderTable);
|
||||||
$("status-filter").addEventListener("change", renderTable);
|
$("status-filter").addEventListener("change", renderTable);
|
||||||
|
$("kpi-total-card")?.addEventListener("click", () => applyStatusFilter(""));
|
||||||
|
$("kpi-online-card")?.addEventListener("click", () => applyStatusFilter("online"));
|
||||||
|
$("kpi-warning-card")?.addEventListener("click", () => applyStatusFilter("warning"));
|
||||||
|
$("kpi-offline-card")?.addEventListener("click", () => applyStatusFilter("offline"));
|
||||||
$("command-device-select")?.addEventListener("change", () => {
|
$("command-device-select")?.addEventListener("change", () => {
|
||||||
$("send-reboot-command").disabled = !$("command-device-select").value;
|
const hasDevice = Boolean($("command-device-select").value);
|
||||||
|
$("send-reboot-command").disabled = !hasDevice;
|
||||||
|
$("send-poweroff-command").disabled = !hasDevice;
|
||||||
$("command-status").textContent = "";
|
$("command-status").textContent = "";
|
||||||
});
|
});
|
||||||
$("send-reboot-command")?.addEventListener("click", sendRebootCommand);
|
$("send-reboot-command")?.addEventListener("click", () => sendDeviceCommand("device.reboot", "send-reboot-command", "Reboot"));
|
||||||
|
$("send-poweroff-command")?.addEventListener("click", () => sendDeviceCommand("device.poweroff", "send-poweroff-command", "Power off"));
|
||||||
$("logout-button").addEventListener("click", () => {
|
$("logout-button").addEventListener("click", () => {
|
||||||
api.clearToken();
|
api.clearToken();
|
||||||
window.location.href = "/ui/admin-login";
|
window.location.href = "/ui/admin-login";
|
||||||
|
|||||||
@ -786,6 +786,10 @@
|
|||||||
renderMerchantFilter();
|
renderMerchantFilter();
|
||||||
renderOutletFilter();
|
renderOutletFilter();
|
||||||
renderTerminalFilter();
|
renderTerminalFilter();
|
||||||
|
const initialQuery = new URLSearchParams(window.location.search).get("q") || "";
|
||||||
|
if (initialQuery && searchInput && !searchInput.value) {
|
||||||
|
searchInput.value = initialQuery;
|
||||||
|
}
|
||||||
applyFilters();
|
applyFilters();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[transaction-history] failed loading", error);
|
console.error("[transaction-history] failed loading", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user