David Patricio Escamiilla Marquez
Published © MIT

TrackProd: IIoT QR Traceability System

Real-time industrial traceability system using QR and IIoT to monitor, track, and optimize production processes in SMEs

AdvancedWork in progressOver 1 day114
TrackProd: IIoT QR Traceability System

Things used in this project

Hardware components

Arduino UNO Q
Arduino UNO Q
×1
LED (generic)
LED (generic)
These LEDs represent an industrial stack light (signal tower), where each color indicates system status: green (normal operation), yellow (warning), red (error), and blue (communication failure).
×4
Buzzer
Buzzer
The buzzer provides audible alerts for abnormal conditions, such as system warnings or failures, complementing the industrial stack light for real-time operator notification.
×1
Nextion NX4827T043 - 4.3” TFT LCD Intelligent Touch Display
Itead Nextion NX4827T043 - 4.3” TFT LCD Intelligent Touch Display
×1
Relay (generic)
×5
LoRa Module
Wireless-Tag LoRa Module
This module represents a LoRa SX1278 transceiver used for long-range wireless communication between devices in the system. It enables reliable data transmission in industrial environments where traditional connectivity is limited.
×1
5V 2.5A Switching Power Supply
Digilent 5V 2.5A Switching Power Supply
Industrial-grade AC-DC switching power supply used to convert 120V AC into a stable DC voltage (5V/6V) for powering the IIoT system components, ensuring reliable and continuous operation.
×1

Software apps and online services

Arduino IDE
Arduino IDE
VS Code
Microsoft VS Code
Arduino App Lab

Hand tools and fabrication machines

Mastech MS8217 Autorange Digital Multimeter
Digilent Mastech MS8217 Autorange Digital Multimeter
Multitool, Screwdriver
Multitool, Screwdriver
Crimp Tool, Heavy-Duty
Crimp Tool, Heavy-Duty
Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free
Premium Female/Male Extension Jumper Wires, 40 x 6" (150mm)
Premium Female/Male Extension Jumper Wires, 40 x 6" (150mm)

Story

Read more

Schematics

IIoT Hardware Architecture and Circuit Diagram

This diagram represents the hardware architecture of the TrackProd IIoT system for real-time industrial traceability.

The system is designed to be implemented using the Arduino UNO Q as the main controller, providing advanced processing, connectivity, and scalability for Industrial IoT applications.

In this diagram, an Arduino UNO R4 WiFi is used as a visual reference due to the lack of an official Arduino UNO Q representation in Fritzing. However, the actual system architecture and implementation are intended for the Arduino UNO Q platform.

The system integrates a QR code reader module, a relay module, an industrial stack light (signal tower), and a Nextion display for user interaction and control. Additionally, a LoRa communication module is used for long-range wireless data transmission between devices, enabling reliable communication in industrial environments where traditional connectivity may be limited.

A power supply module converts 120V AC into a stable DC power source for system operation.

Data is collected and transmitted—either directly or via LoRa—to a web-based platform, where it is stored and visualized in real time, enabling monitoring, traceability, and process optimization in industrial environments.

Code

TrackProd – Remote LoRa Node

C/C++
This code implements the remote node of the TrackProd IIoT system.

It manages the local hardware, including the Nextion HMI, industrial signal tower, relays, buzzer, and LoRa communication.

The node collects local events and system information, displays relevant data on the Nextion interface, and transmits the information wirelessly through LoRa to the main TrackProd node.

Visual and audible indicators provide immediate feedback to the operator about system and communication status.
/*
  TrackProd_QR_LoRa_TX.ino
  --------------------------------------------------------
  Nodo de lectura QR (etapa de producción). Tiene TODO lo local:

    - Lectura de QR (UART -- integración pendiente, con
      fallback de prueba por botón mientras no llega el lector)
    - Pantalla Nextion HMI (estado local: producto leído,
      contadores de lecturas OK/error)
    - Torreta industrial + buzzer (módulo de 4 relés), según
      el estado local del nodo
    - Transmisión por LoRa hacia el receptor, con el paquete
      YA armado y listo (producto, estado, contadores) para
      que el receptor solo lo suba a la web/Python sin tener
      que calcular nada.

  Este nodo NO tiene nada de web ni de Python: no incluye
  Arduino_RouterBridge, no arma JSON, no sabe que existe un
  backend. Su única salida es el paquete LoRa.
--------------------------------------------------------- */

#include <SPI.h>
#include <LoRa.h>
#include "SoftwareSerial.h"

// ============================================================
// LoRa
// ============================================================

#define LORA_SS   9
#define LORA_RST  8
#define LORA_DIO0 2
#define LORA_FREQ 433E6
#define SF        9
#define BW        200E3
#define CR        5
#define TX_POWER  17
#define SYNC_WORD 0x34

#define PACKET_DATA 0xA1
#define PACKET_ACK  0xA2

// ============================================================
// Nextion HMI
// ============================================================

SoftwareSerial miSerial(4, 5); // RX, TX

// ============================================================
// Módulo de 4 relés (torreta + buzzer)
// ============================================================

#define RELAY_VERDE     6
#define RELAY_AMARILLO  7
#define RELAY_ROJO      A0
#define RELAY_BUZZER    A1

// ============================================================
// Lector QR (UART) -- integración pendiente
// ============================================================
// Cuando llegue el módulo físico se conecta por UART y se
// reemplaza el cuerpo de leerQR() por el parseo real.
// #define QR_RX_PIN ...
// #define QR_TX_PIN ...

#define BOTON_PRUEBA_PIN 3   // dispara una lectura simulada mientras no hay lector físico

// ============================================================
// CLAVE XOR
// ============================================================

const uint8_t XOR_KEY[] = {
  0x52, 0xA7, 0x31, 0xC8,
  0x19, 0xD4, 0x6B, 0xE2
};
const uint8_t XOR_KEY_LEN = sizeof(XOR_KEY);

// ============================================================
// PAQUETES
// ============================================================
// El DataPacket ya va "listo": trae producto, estado local y
// contadores, para que el receptor solo lo reenvíe a la web.

struct __attribute__((packed)) DataPacket {
  uint16_t magic;
  uint8_t  type;
  uint32_t seq;
  uint32_t timestamp;
  uint16_t productoId;
  uint8_t  estado;         // 0=ok 1=advertencia 2=critico (local)
  uint16_t lecturasOk;
  uint16_t lecturasError;
  uint16_t crc;
};

struct __attribute__((packed)) AckPacket {
  uint16_t magic;
  uint8_t  type;
  uint32_t seq;
  uint32_t timestamp;
  uint16_t crc;
};

// ============================================================
// ESTADO LOCAL DEL NODO
// ============================================================

uint32_t secuencia = 0;

uint16_t productoActual   = 0;
uint16_t lecturasOk       = 0;
uint16_t lecturasError    = 0;
uint8_t  estadoLocal      = 0; // 0=ok 1=advertencia 2=critico

const uint8_t  MAX_REINTENTOS = 3;
const uint16_t TIMEOUT_ACK_MS = 500;

String rxNextion = "";
unsigned long ultimoDebounceBoton = 0;

// ============================================================
// CRC16
// ============================================================

uint16_t calcularCRC(const uint8_t *data, size_t len) {
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < len; i++) {
    crc ^= data[i];
    for (uint8_t j = 0; j < 8; j++) {
      if (crc & 1) crc = (crc >> 1) ^ 0xA001;
      else         crc >>= 1;
    }
  }
  return crc;
}

// ============================================================
// XOR
// ============================================================

void aplicarXOR(uint8_t *data, size_t len, uint32_t seq) {
  for (size_t i = 0; i < len; i++) {
    uint8_t seqByte = (seq >> ((i % 4) * 8)) & 0xFF;
    data[i] ^= XOR_KEY[i % XOR_KEY_LEN] ^ seqByte;
  }
}

// ============================================================
// NEXTION (estado local del nodo QR)
// ============================================================

void enviarNextion(const String &comando) {
  miSerial.print(comando);
  miSerial.write(0xFF);
  miSerial.write(0xFF);
  miSerial.write(0xFF);
}

void actualizarPantallaNextion() {

  enviarNextion("Menu.t0.txt=\"" + String(productoActual) + "\"");
  enviarNextion("Menu.x0.val=" + String(lecturasOk));
  enviarNextion("Menu.x1.val=" + String(lecturasError));

  int pic = (estadoLocal == 0) ? 8 : (estadoLocal == 1) ? 7 : 6;
  enviarNextion("Menu.p1.pic=" + String(pic));
}

void revisarNextion() {
  while (miSerial.available()) {
    char c = miSerial.read();
    if ((uint8_t)c == 0xFF) continue;
    rxNextion += c;
    if (rxNextion.length() > 0) {
      actualizarPantallaNextion();
      rxNextion = "";
      return;
    }
    if (rxNextion.length() > 30) rxNextion = "";
  }
}

// ============================================================
// TORRETA INDUSTRIAL + BUZZER (estado local del nodo QR)
// ============================================================

void aplicarSenalizacion() {
  digitalWrite(RELAY_VERDE,    estadoLocal == 0 ? HIGH : LOW);
  digitalWrite(RELAY_AMARILLO, estadoLocal == 1 ? HIGH : LOW);
  digitalWrite(RELAY_ROJO,     estadoLocal == 2 ? HIGH : LOW);
  digitalWrite(RELAY_BUZZER,   estadoLocal == 2 ? HIGH : LOW);
}

void actualizarEstadoLocal() {
  if (lecturasError > 5) {
    estadoLocal = 2; // critico
  } else if (lecturasError > 0) {
    estadoLocal = 1; // advertencia
  } else {
    estadoLocal = 0; // ok
  }
  aplicarSenalizacion();
}

// ============================================================
// LECTOR QR -- integración pendiente
// ============================================================
//
// Devuelve true si hay una lectura nueva y la deja en productoId.
// Fallback con botón mientras no llega el lector físico; cuando
// llegue, solo se cambia el cuerpo de esta función.

bool leerQR(uint16_t &productoId) {

  // --- lectura real (pendiente) ---
  // if (SerialQR.available()) {
  //   // parsear trama del lector y extraer productoId
  //   return true;
  // }
  // return false;

  // --- fallback de prueba con botón ---
  if (digitalRead(BOTON_PRUEBA_PIN) == LOW) {
    if (millis() - ultimoDebounceBoton > 300) {
      ultimoDebounceBoton = millis();
      productoId = (uint16_t)(millis() % 1000); // id simulado
      return true;
    }
  }
  return false;
}

// ============================================================
// ENVÍO LoRa CON ESPERA DE ACK
// ============================================================

bool esperarAck(uint32_t seqEsperada) {

  unsigned long inicio = millis();

  while (millis() - inicio < TIMEOUT_ACK_MS) {

    int packetSize = LoRa.parsePacket();
    if (packetSize != sizeof(AckPacket)) continue;

    AckPacket ack;
    LoRa.readBytes((uint8_t *)&ack, sizeof(ack));

    uint8_t *datos = ((uint8_t *)&ack) + 7;
    aplicarXOR(datos, sizeof(AckPacket) - 7, ack.seq);

    if (ack.magic != 0xBEEF || ack.type != PACKET_ACK) continue;

    uint16_t crcRecibido = ack.crc;
    ack.crc = 0;
    uint16_t crcCalculado = calcularCRC((uint8_t *)&ack, sizeof(ack));

    if (crcRecibido != crcCalculado) continue;
    if (ack.seq != seqEsperada) continue;

    return true;
  }

  return false;
}

void enviarEventoQR(uint16_t productoId) {

  secuencia++;

  DataPacket paquete;
  paquete.magic          = 0xBEEF;
  paquete.type           = PACKET_DATA;
  paquete.seq            = secuencia;
  paquete.timestamp      = millis();
  paquete.productoId     = productoId;
  paquete.estado         = estadoLocal;
  paquete.lecturasOk     = lecturasOk;
  paquete.lecturasError  = lecturasError;
  paquete.crc            = 0;

  // CRC calculado en claro
  paquete.crc = calcularCRC((uint8_t *)&paquete, sizeof(paquete));

  // Encriptar solo desde timestamp en adelante (offset 7)
  uint8_t *datos = ((uint8_t *)&paquete) + 7;
  aplicarXOR(datos, sizeof(DataPacket) - 7, paquete.seq);

  bool confirmado = false;

  for (uint8_t intento = 0; intento < MAX_REINTENTOS && !confirmado; intento++) {

    LoRa.beginPacket();
    LoRa.write((uint8_t *)&paquete, sizeof(paquete));
    LoRa.endPacket();

    confirmado = esperarAck(secuencia);
  }

  if (!confirmado) {
    lecturasError++;
  } else {
    lecturasOk++;
  }

  actualizarEstadoLocal();
  actualizarPantallaNextion();
}

// ============================================================
// SETUP / LOOP
// ============================================================

void setup() {

  Serial.begin(9600);
  miSerial.begin(9600);

  pinMode(BOTON_PRUEBA_PIN, INPUT_PULLUP);
  pinMode(RELAY_VERDE, OUTPUT);
  pinMode(RELAY_AMARILLO, OUTPUT);
  pinMode(RELAY_ROJO, OUTPUT);
  pinMode(RELAY_BUZZER, OUTPUT);

  LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);

  if (!LoRa.begin(LORA_FREQ)) {
    Serial.println(F("ERROR: no se pudo iniciar LoRa"));
    while (1) delay(100);
  }

  LoRa.setSpreadingFactor(SF);
  LoRa.setSignalBandwidth(BW);
  LoRa.setCodingRate4(CR);
  LoRa.setSyncWord(SYNC_WORD);
  LoRa.setTxPower(TX_POWER);
  LoRa.enableCrc();

  actualizarEstadoLocal();
  actualizarPantallaNextion();

  Serial.println(F("=== TrackProd - Nodo QR (completo) listo ==="));
}

void loop() {

  uint16_t productoId;

  if (leerQR(productoId)) {
    productoActual = productoId;
    enviarEventoQR(productoId);
  }

  revisarNextion();
}

TrackProd – Main LoRa Node

C/C++
This code implements the main communication node of the TrackProd IIoT system.

It receives information transmitted by remote TrackProd nodes through LoRa and acts as the communication bridge between the wireless network and the processing layer.

Received data is validated and forwarded to the Arduino UNO Q, where it can be processed by the Python application and transferred to the TrackProd database and web platform.

This architecture allows multiple remote nodes to communicate with a centralized IIoT system.
/*
  TrackProd_Receptor_Principal.ino
  --------------------------------------------------------
  Nodo Receptor Principal (Arduino UNO Q). Es la estación central:

    - Tiene su PROPIA estación local: lector QR (UART -- integración
      pendiente, con fallback de botón), pantalla Nextion, torreta
      industrial + buzzer (módulo de 4 relés). Mismos componentes
      que el nodo remoto (TrackProd_QR_LoRa_TX.ino).
    - Además RECIBE por LoRa los paquetes YA ARMADOS por el nodo QR
      remoto: valida CRC+XOR, responde ACK y lleva estadísticas de
      enlace (recibidos, perdidos, duplicados, corruptos, RSSI/SNR,
      PDR/PER).
    - Expone TODO (estación local + estación remota) por Bridge para
      que Python lo suba a la web (get_data / notify). Es el ÚNICO
      nodo que habla con Python.
--------------------------------------------------------- */

#include <SPI.h>
#include <LoRa.h>
#include <Arduino_RouterBridge.h>
#include "SoftwareSerial.h"

// ============================================================
// LoRa
// ============================================================

#define LORA_SS   9
#define LORA_RST  8
#define LORA_DIO0 2
#define LORA_FREQ 433E6
#define SF        9
#define BW        200E3
#define CR        5
#define TX_POWER  17
#define SYNC_WORD 0x34

#define PACKET_DATA 0xA1
#define PACKET_ACK  0xA2

// ============================================================
// Nextion HMI (estación local del Principal)
// ============================================================

SoftwareSerial miSerial(4, 5); // RX, TX

// ============================================================
// Módulo de 4 relés (torreta + buzzer) -- estación local
// ============================================================

#define RELAY_VERDE     6
#define RELAY_AMARILLO  7
#define RELAY_ROJO      A0
#define RELAY_BUZZER    A1

// ============================================================
// Lector QR local (UART) -- integración pendiente
// ============================================================
// Cuando llegue el módulo físico se conecta por UART y se
// reemplaza el cuerpo de leerQR() por el parseo real.
// #define QR_RX_PIN ...
// #define QR_TX_PIN ...

#define BOTON_PRUEBA_PIN 3 // dispara una lectura simulada mientras no hay lector físico

// ============================================================
// CLAVE XOR (idéntica al nodo QR remoto)
// ============================================================

const uint8_t XOR_KEY[] = {
  0x52, 0xA7, 0x31, 0xC8,
  0x19, 0xD4, 0x6B, 0xE2
};
const uint8_t XOR_KEY_LEN = sizeof(XOR_KEY);

// ============================================================
// PAQUETES (idénticos al nodo QR remoto)
// ============================================================

struct __attribute__((packed)) DataPacket {
  uint16_t magic;
  uint8_t  type;
  uint32_t seq;
  uint32_t timestamp;
  uint16_t productoId;
  uint8_t  estado;         // estado local calculado en el nodo remoto
  uint16_t lecturasOk;
  uint16_t lecturasError;
  uint16_t crc;
};

struct __attribute__((packed)) AckPacket {
  uint16_t magic;
  uint8_t  type;
  uint32_t seq;
  uint32_t timestamp;
  uint16_t crc;
};

// ============================================================
// ESTADO -- ESTACIÓN LOCAL (propia del Principal)
// ============================================================

uint16_t productoLocal      = 0;
uint16_t lecturasOkLocal    = 0;
uint16_t lecturasErrorLocal = 0;
uint8_t  estadoLocal        = 0; // 0=ok 1=advertencia 2=critico

String rxNextion = "";
unsigned long ultimoDebounceBoton = 0;

// ============================================================
// ESTADO -- ESTACIÓN REMOTA (recibida por LoRa)
// ============================================================

uint32_t ultimoPaquete = 0;

uint16_t productoRemoto      = 0;
uint8_t  estadoRemoto        = 0;
uint16_t lecturasOkRemoto    = 0;
uint16_t lecturasErrorRemoto = 0;

uint32_t recibidos   = 0;
uint32_t perdidos    = 0;
uint32_t duplicados  = 0;
uint32_t corruptos   = 0;

long  rssiActual = 0;
float snrActual  = 0;
long  rssiMin = 0, rssiMax = -200;
float snrMin  = 100, snrMax = -100;
float rssiPromedio = 0, snrPromedio = 0;

unsigned long inicioPrueba = 0;

String lastData = "{}";

// ============================================================
// CRC16
// ============================================================

uint16_t calcularCRC(const uint8_t *data, size_t len) {
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < len; i++) {
    crc ^= data[i];
    for (uint8_t j = 0; j < 8; j++) {
      if (crc & 1) crc = (crc >> 1) ^ 0xA001;
      else         crc >>= 1;
    }
  }
  return crc;
}

// ============================================================
// XOR
// ============================================================

void aplicarXOR(uint8_t *data, size_t len, uint32_t seq) {
  for (size_t i = 0; i < len; i++) {
    uint8_t seqByte = (seq >> ((i % 4) * 8)) & 0xFF;
    data[i] ^= XOR_KEY[i % XOR_KEY_LEN] ^ seqByte;
  }
}

// ============================================================
// NEXTION -- SOLO estación local
// ============================================================
// La estación remota (recibida por LoRa) se procesa por detrás
// y se sube a Python; no se muestra en esta pantalla.

void enviarNextion(const String &comando) {
  miSerial.print(comando);
  miSerial.write(0xFF);
  miSerial.write(0xFF);
  miSerial.write(0xFF);
}

void actualizarPantallaNextion() {

  enviarNextion("Menu.t0.txt=\"" + String(productoLocal) + "\"");
  enviarNextion("Menu.x0.val=" + String(lecturasOkLocal));
  enviarNextion("Menu.x1.val=" + String(lecturasErrorLocal));
  int picLocal = (estadoLocal == 0) ? 8 : (estadoLocal == 1) ? 7 : 6;
  enviarNextion("Menu.p1.pic=" + String(picLocal));
}

void revisarNextion() {
  while (miSerial.available()) {
    char c = miSerial.read();
    if ((uint8_t)c == 0xFF) continue;
    rxNextion += c;
    if (rxNextion.length() > 0) {
      actualizarPantallaNextion();
      rxNextion = "";
      return;
    }
    if (rxNextion.length() > 30) rxNextion = "";
  }
}

// ============================================================
// TORRETA INDUSTRIAL + BUZZER -- estación local del Principal
// ============================================================
// Solo refleja el estado LOCAL. El estado remoto ya fue
// señalizado físicamente en el nodo QR remoto; aquí solo se
// muestra en Nextion/JSON para no duplicar torretas por un
// evento que ocurrió en otra estación.

void aplicarSenalizacion() {
  digitalWrite(RELAY_VERDE,    estadoLocal == 0 ? HIGH : LOW);
  digitalWrite(RELAY_AMARILLO, estadoLocal == 1 ? HIGH : LOW);
  digitalWrite(RELAY_ROJO,     estadoLocal == 2 ? HIGH : LOW);
  digitalWrite(RELAY_BUZZER,   estadoLocal == 2 ? HIGH : LOW);
}

void actualizarEstadoLocal() {
  if (lecturasErrorLocal > 5) {
    estadoLocal = 2; // critico
  } else if (lecturasErrorLocal > 0) {
    estadoLocal = 1; // advertencia
  } else {
    estadoLocal = 0; // ok
  }
  aplicarSenalizacion();
}

// ============================================================
// LECTOR QR LOCAL -- integración pendiente
// ============================================================
//
// Devuelve true si hay una lectura nueva y la deja en productoId.
// Fallback con botón mientras no llega el lector físico; cuando
// llegue, solo se cambia el cuerpo de esta función.

bool leerQR(uint16_t &productoId) {

  // --- lectura real (pendiente) ---
  // if (SerialQR.available()) {
  //   // parsear trama del lector y extraer productoId
  //   return true;
  // }
  // return false;

  // --- fallback de prueba con botón ---
  if (digitalRead(BOTON_PRUEBA_PIN) == LOW) {
    if (millis() - ultimoDebounceBoton > 300) {
      ultimoDebounceBoton = millis();
      productoId = (uint16_t)(millis() % 1000); // id simulado
      return true;
    }
  }
  return false;
}

void registrarLecturaLocal(uint16_t productoId) {
  productoLocal = productoId;
  lecturasOkLocal++; // cuando llegue el lector real, un fallo de
                      // checksum/timeout debe incrementar
                      // lecturasErrorLocal en vez de OK
  actualizarEstadoLocal();
  actualizarPantallaNextion();
}

// ============================================================
// ACK (hacia el nodo remoto)
// ============================================================

void enviarACK(uint32_t seq, uint32_t timestamp) {

  AckPacket ack;
  ack.magic = 0xBEEF;
  ack.type  = PACKET_ACK;
  ack.seq = seq;
  ack.timestamp = timestamp;
  ack.crc = 0;
  ack.crc = calcularCRC((uint8_t *)&ack, sizeof(ack));

  uint8_t *datos = ((uint8_t *)&ack) + 7;
  aplicarXOR(datos, sizeof(AckPacket) - 7, ack.seq);

  LoRa.beginPacket();
  LoRa.write((uint8_t *)&ack, sizeof(ack));
  LoRa.endPacket();
}

// ============================================================
// JSON PARA PYTHON (estación local + estación remota)
// ============================================================

void armarJSON() {

  uint32_t esperados = recibidos + perdidos;
  float pdr = (esperados > 0) ? ((float)recibidos / esperados) * 100.0 : 0;
  float per = 100.0 - pdr;

  float segundos = (millis() - inicioPrueba) / 1000.0;
  float throughput = (segundos > 0) ? ((recibidos * sizeof(DataPacket) * 8.0) / segundos) / 1000.0 : 0;

  lastData  = "{";

  lastData += "\"local\":{";
  lastData += "\"producto\":" + String(productoLocal) + ",";
  lastData += "\"estado\":" + String(estadoLocal) + ",";
  lastData += "\"lecturas_ok\":" + String(lecturasOkLocal) + ",";
  lastData += "\"lecturas_error\":" + String(lecturasErrorLocal);
  lastData += "},";

  lastData += "\"remoto\":{";
  lastData += "\"producto\":" + String(productoRemoto) + ",";
  lastData += "\"estado\":" + String(estadoRemoto) + ",";
  lastData += "\"lecturas_ok\":" + String(lecturasOkRemoto) + ",";
  lastData += "\"lecturas_error\":" + String(lecturasErrorRemoto) + ",";
  lastData += "\"recibidos\":" + String(recibidos) + ",";
  lastData += "\"perdidos\":" + String(perdidos) + ",";
  lastData += "\"duplicados\":" + String(duplicados) + ",";
  lastData += "\"corruptos\":" + String(corruptos) + ",";
  lastData += "\"pdr\":" + String(pdr, 2) + ",";
  lastData += "\"per\":" + String(per, 2) + ",";
  lastData += "\"rssi\":" + String(rssiActual) + ",";
  lastData += "\"snr\":" + String(snrActual, 2) + ",";
  lastData += "\"rssi_avg\":" + String(rssiPromedio, 2) + ",";
  lastData += "\"snr_avg\":" + String(snrPromedio, 2) + ",";
  lastData += "\"throughput\":" + String(throughput, 4);
  lastData += "},";

  lastData += "\"uptime_ms\":" + String(millis());
  lastData += "}";
}

// ============================================================
// RECEPCIÓN LoRa (estación remota)
// ============================================================

void revisarLoRa() {

  int packetSize = LoRa.parsePacket();
  if (packetSize <= 0) return;

  if (packetSize != sizeof(DataPacket)) {
    while (LoRa.available()) LoRa.read();
    corruptos++;
    return;
  }

  DataPacket paquete;
  LoRa.readBytes((uint8_t *)&paquete, sizeof(paquete));

  rssiActual = LoRa.packetRssi();
  snrActual  = LoRa.packetSnr();

  if (paquete.magic != 0xBEEF || paquete.type != PACKET_DATA) return;

  uint32_t secuencia = paquete.seq;

  uint8_t *datos = ((uint8_t *)&paquete) + 7;
  aplicarXOR(datos, sizeof(DataPacket) - 7, secuencia);

  uint16_t crcRecibido = paquete.crc;
  paquete.crc = 0;
  uint16_t crcCalculado = calcularCRC((uint8_t *)&paquete, sizeof(paquete));

  if (crcRecibido != crcCalculado) {
    corruptos++;
    return;
  }

  enviarACK(secuencia, paquete.timestamp);

  bool duplicado = false;

  if (ultimoPaquete == 0) {
    ultimoPaquete = secuencia;
  } else if (secuencia == ultimoPaquete) {
    duplicados++;
    duplicado = true;
  } else if (secuencia > ultimoPaquete) {
    if (secuencia > ultimoPaquete + 1) {
      perdidos += secuencia - ultimoPaquete - 1;
    }
    ultimoPaquete = secuencia;
  }

  if (!duplicado) {

    recibidos++;

    if (recibidos == 1) {
      rssiMin = rssiActual; rssiMax = rssiActual;
      snrMin  = snrActual;  snrMax  = snrActual;
    }
    if (rssiActual < rssiMin) rssiMin = rssiActual;
    if (rssiActual > rssiMax) rssiMax = rssiActual;
    if (snrActual < snrMin) snrMin = snrActual;
    if (snrActual > snrMax) snrMax = snrActual;

    rssiPromedio += (rssiActual - rssiPromedio) / recibidos;
    snrPromedio  += (snrActual  - snrPromedio)  / recibidos;

    productoRemoto      = paquete.productoId;
    estadoRemoto        = paquete.estado;
    lecturasOkRemoto    = paquete.lecturasOk;
    lecturasErrorRemoto = paquete.lecturasError;
  }

  armarJSON();
  Bridge.notify("new_data", lastData);
}

// ============================================================
// BRIDGE (único punto de contacto con Python)
// ============================================================

String get_data() {
  return lastData;
}

// ============================================================
// SETUP
// ============================================================

void setup() {

  Bridge.begin();
  Bridge.provide("get_data", get_data);

  Monitor.begin();
  miSerial.begin(9600);

  pinMode(BOTON_PRUEBA_PIN, INPUT_PULLUP);
  pinMode(RELAY_VERDE, OUTPUT);
  pinMode(RELAY_AMARILLO, OUTPUT);
  pinMode(RELAY_ROJO, OUTPUT);
  pinMode(RELAY_BUZZER, OUTPUT);

  LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);

  if (!LoRa.begin(LORA_FREQ)) {
    Monitor.println(F("ERROR: no se pudo iniciar LoRa"));
    while (1) delay(100);
  }

  LoRa.setSpreadingFactor(SF);
  LoRa.setSignalBandwidth(BW);
  LoRa.setCodingRate4(CR);
  LoRa.setSyncWord(SYNC_WORD);
  LoRa.setTxPower(TX_POWER);
  LoRa.enableCrc();

  inicioPrueba = millis();

  actualizarEstadoLocal();
  actualizarPantallaNextion();
  armarJSON();

  Monitor.println(F("=== TrackProd - Receptor Principal (local + remoto) listo ==="));
}

// ============================================================
// LOOP
// ============================================================

void loop() {

  uint16_t productoId;

  if (leerQR(productoId)) {
    registrarLecturaLocal(productoId);
  }

  revisarNextion();
  revisarLoRa();
}

TrackProd – Python Data Processing

Python
This Python application implements the data-processing layer of TrackProd on the Arduino UNO Q.

It receives information from the hardware and communication layer, processes the incoming data, and prepares it for storage and visualization.

The application connects the physical IIoT infrastructure with the TrackProd database and web services, allowing information received from remote nodes to become available to the digital platform.

This layer takes advantage of the Linux environment available on the Arduino UNO Q.
"""
TrackProd — App Python (Arduino UNO Q)

Recibe por Bridge el JSON que arma el Receptor Principal
(armarJSON(): {"local": {...}, "remoto": {...}, "uptime_ms": ...}),
lo guarda en SQLite y lo publica en la WebUI en tiempo real.
"""

import json
import logging
from datetime import datetime, timezone, timedelta

from arduino.app_utils import *
from arduino.app_bricks.web_ui import WebUI
from arduino.app_bricks.dbstorage_sqlstore import SQLStore

# =====================================================================
# CONFIGURACIÓN
# =====================================================================
TZ_OFFSET_HOURS = -6
DB_PATH = "trackprod.db"
HISTORIAL_LIMIT = 500

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-7s | %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger("trackprod")

TZ = timezone(timedelta(hours=TZ_OFFSET_HOURS))


def now() -> str:
    return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")


# =====================================================================
# WebUI + BD
# =====================================================================
ui = WebUI()
db = SQLStore(DB_PATH)
db.start()

ultimo_raw = None


def leer_historial(limit: int = HISTORIAL_LIMIT) -> list:
    try:
        rows = db.read("lecturas", order_by="timestamp DESC", limit=limit)
        return list(reversed(rows))
    except TypeError:
        rows = db.read("lecturas", order_by="timestamp DESC")
        return list(reversed(rows))[-limit:]
    except Exception:
        log.exception("Error leyendo historial")
        return []


def on_get_initial_state(client, data):
    historial = leer_historial()
    ui.send_message("full_data", {"historial": historial}, client)

    if historial:
        u = historial[-1]
        log.info(
            "Estado inicial -> %d registros | último: local=%s remoto=%s @ %s",
            len(historial), u.get("local_producto"), u.get("remoto_producto"),
            u.get("timestamp", "?"),
        )


ui.on_message("get_initial_state", on_get_initial_state)


def on_new_data(raw: str):
    """Se dispara con cada Bridge.notify("new_data", ...) del Receptor
    Principal. `raw` es el JSON tal cual lo arma armarJSON() en el .ino:
    {"local": {...}, "remoto": {...}, "uptime_ms": ...}
    """
    global ultimo_raw

    if not raw or raw.strip() == "{}":
        return

    if raw == ultimo_raw:
        return

    try:
        paquete = json.loads(raw)
    except json.JSONDecodeError as e:
        log.warning("JSON inválido: %s | raw=%s", e, raw)
        return

    local = paquete.get("local", {})
    remoto = paquete.get("remoto", {})

    registro = {
        "timestamp": now(),
        "local_producto": local.get("producto"),
        "local_estado": local.get("estado"),
        "local_lecturas_ok": local.get("lecturas_ok"),
        "local_lecturas_error": local.get("lecturas_error"),
        "remoto_producto": remoto.get("producto"),
        "remoto_estado": remoto.get("estado"),
        "remoto_lecturas_ok": remoto.get("lecturas_ok"),
        "remoto_lecturas_error": remoto.get("lecturas_error"),
        "recibidos": remoto.get("recibidos"),
        "perdidos": remoto.get("perdidos"),
        "duplicados": remoto.get("duplicados"),
        "corruptos": remoto.get("corruptos"),
        "pdr": remoto.get("pdr"),
        "per": remoto.get("per"),
        "rssi": remoto.get("rssi"),
        "snr": remoto.get("snr"),
        "rssi_avg": remoto.get("rssi_avg"),
        "snr_avg": remoto.get("snr_avg"),
        "throughput": remoto.get("throughput"),
        "uptime_ms": paquete.get("uptime_ms"),
    }

    try:
        db.store("lecturas", registro)
    except Exception:
        log.exception("Error guardando en BD, se descarta el dato")
        return

    ui.send_message("new_reading", registro)
    ultimo_raw = raw

    log.info(
        "Local: prod=%s estado=%s ok=%s err=%s | Remoto: prod=%s estado=%s "
        "ok=%s err=%s pdr=%s%%",
        registro["local_producto"], registro["local_estado"],
        registro["local_lecturas_ok"], registro["local_lecturas_error"],
        registro["remoto_producto"], registro["remoto_estado"],
        registro["remoto_lecturas_ok"], registro["remoto_lecturas_error"],
        registro["pdr"],
    )


Bridge.provide("new_data", on_new_data)


# =====================================================================
# ARRANQUE
# =====================================================================
if __name__ == "__main__":
    log.info("=" * 60)
    log.info("INICIANDO TRACKPROD APP")
    log.info("=" * 60)
    log.info("Esperando datos push del Receptor Principal...")
    App.run()

TrackProd Web Platform – JavaScript Logic

JavaScript
This code implements the main application logic of the TrackProd web platform.

It manages products, materials, users, system states, traceability records, navigation, and QR-related functions.

The application dynamically updates the interface and connects the different management and monitoring functions of the platform.

This logic enables TrackProd to transform IIoT data into organized information for monitoring and traceability.
let USERS = [
  { id: 1, name: 'Admin Principal', username: 'admin', password: 'admin123', role: 'admin', status: 'activo', created: '2024-01-15' },
  { id: 2, name: 'Carlos Herrera', username: 'operador1', password: 'op123', role: 'operador', status: 'activo', created: '2024-02-03' },
  { id: 3, name: 'Viewer General', username: 'viewer', password: 'view123', role: 'viewer', status: 'activo', created: '2024-03-01' },
];
let nextId = 4;
let currentUser = null;
const ROLE_LABELS = { admin: 'Administrador', operador: 'Operador', viewer: 'Visualizador' };
const ROLE_CLASSES = { admin: 'role-admin', operador: 'role-op', viewer: 'role-viewer' };
const ROLE_ICONS = { admin: '👑', operador: '⚙️', viewer: '👁' };
const AVATAR_COLORS = [['#fee2e2','#9b1c1c'],['#fef3c7','#92400e'],['#dcfce7','#166534'],['#eff6ff','#1d4ed8'],['#ede9fe','#5b21b6'],['#ccfbf1','#0d9488']];
function fillDemo(u, p) { document.getElementById('l-user').value = u; document.getElementById('l-pass').value = p; document.getElementById('l-err').style.display = 'none'; }
function applyBranding(show) {
  const top = document.getElementById('lb-top'), name = document.getElementById('lb-name');
  const sbName = document.getElementById('sbb-name'), sbSub = document.getElementById('sbb-sub');
  if (show) {
    if (top) top.textContent = 'Trazabilidad';
    if (name) name.innerHTML = 'LOGUI<span>NEXT</span>';
    if (sbName) sbName.innerHTML = 'LOGUI<span>NEXT</span>';
    if (sbSub) sbSub.textContent = 'Trazabilidad QR';
    document.title = 'LOGUINEXT — Trazabilidad de Materiales por QR';
  } else {
    if (top) top.textContent = 'Sistema';
    if (name) name.innerHTML = 'Track<span>Prod</span>';
    if (sbName) sbName.innerHTML = 'Track<span>Prod</span>';
    if (sbSub) sbSub.textContent = 'Trazabilidad QR';
    document.title = 'TrackProd — Trazabilidad de Materiales por QR';
  }
}
function toggleLoginLogo() {
  const show = document.getElementById('l-showlogo').checked;
  applyBranding(show);
  localStorage.setItem('loguinext-showlogo', show ? '1' : '0');
}
(function initLoginLogoPref() {
  const saved = localStorage.getItem('loguinext-showlogo');
  document.addEventListener('DOMContentLoaded', () => {
    if (saved === '0') { document.getElementById('l-showlogo').checked = false; }
    applyBranding(saved !== '0');
  });
})();
function doLogin() {
  const u = document.getElementById('l-user').value.trim();
  const p = document.getElementById('l-pass').value;
  const found = USERS.find(x => x.username === u && x.password === p && x.status === 'activo');
  if (!found) { const e = document.getElementById('l-err'); e.style.display = 'flex'; e.style.alignItems = 'center'; e.style.gap = '8px'; return; }
  currentUser = found;
  document.getElementById('login-screen').style.display = 'none';
  document.getElementById('app-screen').style.display = 'block';
  applyRoleUI();
  initOverview();
}
function doLogout() {
  stopScan();
  currentUser = null;
  document.getElementById('login-screen').style.display = 'flex';
  document.getElementById('app-screen').style.display = 'none';
  document.getElementById('l-user').value = ''; document.getElementById('l-pass').value = '';
  document.getElementById('l-err').style.display = 'none';
}
function applyRoleUI() {
  if (!currentUser) return;
  document.getElementById('sb-uname').textContent = currentUser.username;
  document.getElementById('sb-role').textContent = ROLE_LABELS[currentUser.role] || currentUser.role;
  const [bg, cl] = AVATAR_COLORS[currentUser.id % AVATAR_COLORS.length];
  const av = document.getElementById('sb-avatar'); av.style.background = bg; av.style.color = cl; av.textContent = currentUser.name.charAt(0).toUpperCase();
  const badge = document.getElementById('tb-role-badge'); badge.style.display = '';
  if (currentUser.role === 'admin') { badge.style.background = 'rgba(192,22,26,.1)'; badge.style.color = 'var(--red-dark)'; badge.innerHTML = '👑 ADMIN'; }
  else if (currentUser.role === 'operador') { badge.style.background = '#dcfce7'; badge.style.color = '#166534'; badge.innerHTML = '⚙️ OPERADOR'; }
  else { badge.style.background = 'var(--gray-100)'; badge.style.color = 'var(--gray-600)'; badge.innerHTML = '👁 VIEWER'; }
  document.getElementById('nav-users').style.display = isAdmin() ? '' : 'none';
  document.getElementById('bn-users').style.display = isAdmin() ? '' : 'none';
  const editable = canEdit();
  const pairs = [['mat-form-locked', 'mat-form-fields'], ['emp-form-locked', 'emp-form-fields'], ['resp-form-locked', 'resp-form-fields']];
  pairs.forEach(([lockId, fieldId]) => {
    const lockEl = document.getElementById(lockId), fieldEl = document.getElementById(fieldId);
    if (lockEl) lockEl.style.display = editable ? 'none' : 'block';
    if (fieldEl) fieldEl.style.display = editable ? 'block' : 'none';
  });
}
function isAdmin() { return currentUser?.role === 'admin'; }
function canEdit() { return currentUser?.role === 'admin' || currentUser?.role === 'operador'; }
function requirePermission(msg) { showToast(msg || 'Tu rol no tiene permiso para esta acción', 'fa-lock', '#b91c1c'); }
document.addEventListener('keydown', e => { if (document.getElementById('login-screen').style.display !== 'none' && e.key === 'Enter') doLogin(); });
const ESTADOS = {
  DISPONIBLE:  { label: 'Disponible',  cls: 's-disp' },
  RECIBIDO:    { label: 'Recibido',    cls: 's-disp' },
  PRESTADO:    { label: 'Prestado',    cls: 's-prest' },
  EN_USO:      { label: 'En uso',      cls: 's-uso' },
  DEVUELTO:    { label: 'Devuelto',    cls: 's-disp' },
  TRANSFERIDO: { label: 'Transferido', cls: 's-transf' },
  REPARACION:  { label: 'Reparación',  cls: 's-rep' },
  DAÑADO:      { label: 'Dañado',      cls: 's-dan' },
  EXTRAVIADO:  { label: 'Extraviado',  cls: 's-ext' },
  BAJA:        { label: 'Baja',        cls: 's-baja' },
};
function estLabel(k) { return ESTADOS[k]?.label || k; }
function estCls(k) { return ESTADOS[k]?.cls || 's-baja'; }
function sb(k) { return `<span class="sb ${estCls(k)}">${estLabel(k)}</span>`; }
const p2 = n => String(n).padStart(2, '0');
const nowStr = () => { const d = new Date(); return `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`; };
const todayISO = () => { const d = new Date(); return `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())}`; };
const nowHM = () => { const d = new Date(); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; };
let EMPRESAS = [
  { id: 1, name: 'Empresa Juan', contact: '[email protected]' },
  { id: 2, name: 'Empresa Pérez', contact: '[email protected]' },
];
let nextEmpId = 3;
let RESPONSABLES = [
  { id: 1, nombre: 'Pepe', nota: '' },
  { id: 2, nombre: 'Luis', nota: '' },
  { id: 3, nombre: 'Paco', nota: '' },
  { id: 4, nombre: 'David', nota: 'Alumno' },
];
let nextRespId = 5;
function addResponsableIfNew(nombre) {
  if (!nombre) return;
  const clean = nombre.trim();
  if (!clean) return;
  const exists = RESPONSABLES.find(r => r.nombre.toLowerCase() === clean.toLowerCase());
  if (!exists) RESPONSABLES.push({ id: nextRespId++, nombre: clean, nota: '' });
  fillDatalists();
}
let nextMatId = 4;
let nextLoteId = 2;
let PRODUCT_LETTERS = {};   // nombre de material (lower) -> letra
let PRODUCT_LETTER_SEQ = 0; // siguiente letra libre
let PRODUCT_SEQ = {};       // letra -> consecutivo (00-99)
function letraDeProducto(nombre) {
  const key = (nombre || '').trim().toLowerCase();
  if (PRODUCT_LETTERS[key]) return PRODUCT_LETTERS[key];
  const letra = String.fromCharCode(65 + (PRODUCT_LETTER_SEQ % 26));
  PRODUCT_LETTER_SEQ++;
  PRODUCT_LETTERS[key] = letra;
  return letra;
}
function siguienteConsecutivo(letra) {
  const n = (PRODUCT_SEQ[letra] || 0) % 100;
  PRODUCT_SEQ[letra] = n + 1;
  return n;
}
function generarCodigoQR(nombre, offsetMs = 0) {
  const d = new Date(Date.now() + offsetMs);
  const hh = p2(d.getHours()), mi = p2(d.getMinutes()), ss = p2(d.getSeconds());
  const ms = p2(Math.floor(d.getMilliseconds() / 10)); // 000-999 → 00-99
  const letra = letraDeProducto(nombre);
  const num = p2(siguienteConsecutivo(letra));
  return `${hh}${mi}${ss}${ms}${letra}${num}`;
}
let MATERIALES = [
  {
    id: 'MAT-0001', codigo: '090000A00', nombre: 'Arduino Uno R3', empresaId: 1, empresaNombre: 'Empresa Juan',
    loteId: 'LOTE-0001', estado: 'EN_USO', custodioActual: 'David', ubicacion: 'Laboratorio',
    descripcion: 'Arduino Uno R3 con cable USB', observaciones: '', prestamo: null,
    historial: [
      { tipo: 'Recepción', de: 'Empresa Juan', para: 'Pepe', fecha: '2025-04-10 09:00', nota: 'Recepción inicial — lote de 2 Arduinos', por: 'Carlos Herrera', estado: 'RECIBIDO' },
      { tipo: 'Préstamo', de: 'Pepe', para: 'David', fecha: '2025-04-12 10:15', nota: 'Prestado para práctica de alumno', por: 'Carlos Herrera', estado: 'PRESTADO' },
      { tipo: 'Uso', de: 'David', para: 'Prototipo Estación Meteorológica', fecha: '2025-04-13 16:40', nota: 'Instalado y funcionando correctamente', por: 'David', estado: 'EN_USO' },
    ]
  },
  {
    id: 'MAT-0002', codigo: '090005A01', nombre: 'Arduino Uno R3', empresaId: 1, empresaNombre: 'Empresa Juan',
    loteId: 'LOTE-0001', estado: 'DISPONIBLE', custodioActual: 'Pepe', ubicacion: 'Laboratorio',
    descripcion: 'Arduino Uno R3', observaciones: '', prestamo: null,
    historial: [
      { tipo: 'Recepción', de: 'Empresa Juan', para: 'Pepe', fecha: '2025-04-10 09:00', nota: 'Recepción inicial — lote de 2 Arduinos', por: 'Carlos Herrera', estado: 'RECIBIDO' },
    ]
  },
  {
    id: 'MAT-0003', codigo: '081530B00', nombre: 'Sensor DHT22', empresaId: 2, empresaNombre: 'Empresa Pérez',
    loteId: null, estado: 'DISPONIBLE', custodioActual: 'Pepe', ubicacion: 'Almacén',
    descripcion: 'Sensor de temperatura y humedad', observaciones: '', prestamo: null,
    historial: [
      { tipo: 'Recepción', de: 'Empresa Pérez', para: 'Pepe', fecha: '2025-04-14 08:30', nota: 'Recepción inicial del material', por: 'Carlos Herrera', estado: 'RECIBIDO' },
    ]
  },
];
// Sincroniza el generador de códigos con los materiales de ejemplo que ya traen letra asignada
PRODUCT_LETTERS['arduino uno r3'] = 'A'; PRODUCT_LETTER_SEQ = 1; PRODUCT_SEQ['A'] = 2;
PRODUCT_LETTERS['sensor dht22'] = 'B'; PRODUCT_LETTER_SEQ = 2; PRODUCT_SEQ['B'] = 1;
function empresaNombre(id) { const e = EMPRESAS.find(x => x.id === id); return e ? e.name : '—'; }
function matEmpresaNombre(m) { return m.empresaNombre || empresaNombre(m.empresaId) || 'Empresa eliminada'; }
function isVencido(m) { return m.prestamo && m.prestamo.fechaEsperada && m.estado === 'PRESTADO' && m.prestamo.fechaEsperada < todayISO(); }
function tick() {
  const n = new Date();
  const t = `${p2(n.getHours())}:${p2(n.getMinutes())}:${p2(n.getSeconds())}`;
  const top = document.getElementById('top-clk'); if (top) top.textContent = t;
}
setInterval(tick, 1000); tick();
function showToast(msg, icon = 'fa-circle-check', color = '#22c55e') {
  const box = document.getElementById('toast-box');
  const el = document.createElement('div'); el.className = 'toast-msg';
  el.innerHTML = `<i class="fa-solid ${icon}" style="color:${color}"></i>${msg}`;
  box.appendChild(el);
  setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 300); }, 2800);
}
const META = {
  overview: ['DASHBOARD', 'Vista general de materiales'],
  materiales: ['MATERIALES', 'Registro, catálogo y generación de QR'],
  empresas: ['EMPRESAS', 'Empresas que entregan materiales'],
  responsables: ['RESPONSABLES', 'Personas que reciben, usan o custodian materiales'],
  escanear: ['ESCANEAR QR', 'Cámara, celular o lector físico'],
  users: ['GESTIÓN DE USUARIOS', 'Solo administradores pueden acceder'],
};
function go(id) {
  if (id === 'users' && currentUser?.role !== 'admin') { showToast('Acceso restringido a administradores', 'fa-lock', '#b91c1c'); return; }
  if (id !== 'escanear') stopScan();
  document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
  document.getElementById('view-' + id).classList.add('active');
  const [t, s] = META[id] || [id.toUpperCase(), ''];
  document.getElementById('tb-t').textContent = t; document.getElementById('tb-s').textContent = s;
  document.querySelectorAll('.nav-link-tp').forEach(n => n.classList.toggle('active', (n.getAttribute('onclick') || '').includes(`'${id}'`)));
  document.querySelectorAll('.bn-item').forEach(n => n.classList.toggle('active', (n.getAttribute('onclick') || '').includes(`'${id}'`)));
  if (id === 'materiales') { renderMats(); fillEmpresaSelect(); fillDatalists(); }
  if (id === 'empresas') renderEmpresas();
  if (id === 'responsables') renderResponsables();
  if (id === 'users') renderUsers();
  if (id === 'escanear') { setTimeout(() => document.getElementById('scan-manual')?.focus(), 100); }
  document.getElementById('page-content').scrollTo(0, 0);
}
function refreshData() {
  const ic = document.getElementById('ricon'); ic.style.animation = 'spin .7s linear'; setTimeout(() => ic.style.animation = '', 700);
  initOverview();
}
function initOverview() {
  document.getElementById('k-total').textContent = MATERIALES.length;
  document.getElementById('k-disp').textContent = MATERIALES.filter(m => m.estado === 'DISPONIBLE').length;
  document.getElementById('k-prest').textContent = MATERIALES.filter(m => m.estado === 'PRESTADO' || m.estado === 'EN_USO').length;
  document.getElementById('k-rep').textContent = MATERIALES.filter(m => m.estado === 'REPARACION').length;
  document.getElementById('k-ext').textContent = MATERIALES.filter(m => m.estado === 'EXTRAVIADO').length;
  document.getElementById('k-emp').textContent = EMPRESAS.length;
  const moves = [];
  MATERIALES.forEach(m => m.historial.forEach(h => moves.push({ ...h, matId: m.id, matNombre: m.nombre })));
  moves.sort((a, b) => b.fecha.localeCompare(a.fecha));
  document.getElementById('dash-feed').innerHTML = moves.slice(0, 6).map(h => `
    <div class="d-flex align-items-center gap-2 p-2 mb-2 rounded" style="background:var(--gray-50);border:1px solid var(--gray-200)">
      <div style="width:34px;height:34px;background:var(--red-muted);border-radius:8px;display:grid;place-items:center;color:var(--red);flex-shrink:0">
        <i class="fa-solid fa-arrow-right-arrow-left" style="font-size:12px"></i>
      </div>
      <div style="flex:1;min-width:0">
        <div class="fw-bold" style="font-size:12.5px">${h.tipo}: <span class="mono">${h.matId}</span></div>
        <div style="font-size:11px;color:var(--gray-400)">${h.de} → ${h.para}</div>
      </div>
      <div class="mono" style="font-size:9.5px;color:var(--gray-400);flex-shrink:0">${h.fecha.slice(5)}</div>
    </div>`).join('') || '<div style="text-align:center;color:var(--gray-400);font-size:13px;padding:20px">Aún no hay movimientos registrados</div>';
  const prestamos = moves.filter(h => h.tipo === 'Préstamo' || h.tipo === 'Devolución').slice(0, 6);
  document.getElementById('dash-prestamos').innerHTML = prestamos.map(h => `
    <div class="d-flex align-items-center gap-2 p-2 mb-2 rounded" style="background:var(--gray-50);border:1px solid var(--gray-200)">
      <i class="fa-solid ${h.tipo === 'Préstamo' ? 'fa-arrow-up text-primary' : 'fa-arrow-down text-success'}" style="font-size:12px;width:16px;text-align:center"></i>
      <div style="flex:1;min-width:0">
        <div class="fw-bold" style="font-size:12.5px">${h.tipo} · ${h.matId}</div>
        <div style="font-size:11px;color:var(--gray-400)">${h.de} → ${h.para}</div>
      </div>
      <div class="mono" style="font-size:9.5px;color:var(--gray-400)">${h.fecha.slice(5)}</div>
    </div>`).join('') || '<div style="text-align:center;color:var(--gray-400);font-size:12px;padding:14px">Sin préstamos o devoluciones aún</div>';
  const today = todayISO();
  const hoy = moves.filter(h => h.fecha.startsWith(today)).length;
  const porEmp = {};
  MATERIALES.forEach(m => { const n = matEmpresaNombre(m); porEmp[n] = (porEmp[n] || 0) + 1; });
  const porResp = {};
  MATERIALES.forEach(m => { porResp[m.custodioActual] = (porResp[m.custodioActual] || 0) + 1; });
  document.getElementById('dash-por-empresa').innerHTML = `
    <div style="font-size:10px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">Por empresa &nbsp;·&nbsp; ${hoy} movimiento(s) hoy</div>
    ${Object.entries(porEmp).map(([n, c]) => `
      <div class="d-flex align-items-center justify-content-between" style="font-size:12px;padding:4px 0">
        <span>${n}</span><span class="mono fw-bold">${c}</span>
      </div>`).join('') || '<div style="font-size:12px;color:var(--gray-400)">—</div>'}`;
  document.getElementById('dash-por-resp').innerHTML = `
    <div style="font-size:10px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">Por responsable</div>
    ${Object.entries(porResp).map(([n, c]) => `
      <div class="d-flex align-items-center justify-content-between" style="font-size:12px;padding:4px 0">
        <span>${n}</span><span class="mono fw-bold">${c}</span>
      </div>`).join('') || '<div style="font-size:12px;color:var(--gray-400)">—</div>'}`;
  document.getElementById('rec-tbl').innerHTML =
    `<thead><tr><th>QR</th><th>ID</th><th>Material</th><th>Empresa</th><th>Responsable</th><th>Estado</th><th>Ubicación</th><th>Últ. mov.</th><th></th></tr></thead>
     <tbody>${MATERIALES.slice(0, 8).map(matRow).join('')}</tbody>`;
}
function matRow(m) {
  const ultimo = m.historial[m.historial.length - 1];
  const vencido = isVencido(m) ? ` <span class="sb s-dan" title="Fecha esperada: ${m.prestamo.fechaEsperada}"><i class="fa-solid fa-clock"></i> Vencido</span>` : '';
  return `<tr>
    <td><span class="qrt" style="cursor:pointer" onclick="openMatModal('${m.id}')"><i class="fa-solid fa-qrcode"></i></span></td>
    <td class="mono fw-bold" style="font-size:12px;color:var(--red)">${m.id}</td>
    <td class="fw-bold">${m.nombre}${m.loteId ? ` <span class="mono" style="font-size:9px;color:var(--gray-400);cursor:pointer" onclick="openLoteModal('${m.loteId}')">[${m.loteId}]</span>` : ''}</td>
    <td>${matEmpresaNombre(m)}</td>
    <td>${m.custodioActual}</td>
    <td>${sb(m.estado)}${vencido}</td>
    <td style="font-size:12px;color:var(--gray-600)"><i class="fa-solid fa-location-dot me-1" style="color:var(--gray-400)"></i>${m.ubicacion || '—'}</td>
    <td class="mono" style="font-size:10.5px;color:var(--gray-400)">${ultimo ? ultimo.fecha : '—'}</td>
    <td><button class="abtn" onclick="openMatModal('${m.id}')">VER</button></td>
  </tr>`;
}
function loteResumen(loteId) {
  const items = MATERIALES.filter(m => m.loteId === loteId);
  return { total: items.length, items };
}
function openLoteModal(loteId) {
  const { items } = loteResumen(loteId);
  if (!items.length) return;
  const rows = items.map(m => `
    <tr>
      <td class="mono fw-bold" style="font-size:12px;color:var(--red)">${m.id}</td>
      <td><span class="qrt">${m.codigo}</span></td>
      <td>${m.custodioActual}</td>
      <td>${sb(m.estado)}</td>
      <td><button class="abtn" onclick="openMatModal('${m.id}')">VER</button></td>
    </tr>`).join('');
  document.getElementById('mat-modal-body').innerHTML = `
    <div class="d-flex align-items-start justify-content-between mb-3">
      <div>
        <div class="cond fw-bold" style="font-size:18px;color:var(--red)">${loteId}</div>
        <div style="font-size:12px;color:var(--gray-400)">${items.length} unidades de "${items[0].nombre}" recibidas de ${matEmpresaNombre(items[0])} en el mismo lote — cada una con su propio QR e historial.</div>
      </div>
      <button type="button" class="ibtn" data-bs-dismiss="modal">✕</button>
    </div>
    <div class="tbl-wrap"><table class="tbl">
      <thead><tr><th>ID</th><th>Código QR</th><th>Custodio actual</th><th>Estado</th><th></th></tr></thead>
      <tbody>${rows}</tbody>
    </table></div>`;
  new bootstrap.Modal(document.getElementById('matModal')).show();
}
function fillDatalists() {
  const respList = document.getElementById('resp-list');
  if (respList) respList.innerHTML = RESPONSABLES.map(r => `<option value="${r.nombre}">`).join('');
  const ubicList = document.getElementById('ubic-list');
  if (ubicList) {
    const ubics = [...new Set(MATERIALES.map(m => m.ubicacion).filter(Boolean))];
    ubicList.innerHTML = ubics.map(u => `<option value="${u}">`).join('');
  }
}
function fillEmpresaSelect() {
  const sel = document.getElementById('nm-empresa');
  sel.innerHTML = EMPRESAS.map(e => `<option value="${e.id}">${e.name}</option>`).join('') || '<option value="">Registra una empresa primero</option>';
}
function registrarEmpresa() {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede registrar empresas.'); return; }
  const name = document.getElementById('ne-nombre').value.trim();
  const contact = document.getElementById('ne-contacto').value.trim();
  if (!name) { showToast('Ingresa el nombre de la empresa', 'fa-triangle-exclamation', '#d97706'); return; }
  EMPRESAS.push({ id: nextEmpId++, name, contact });
  document.getElementById('ne-nombre').value = ''; document.getElementById('ne-contacto').value = '';
  renderEmpresas(); fillEmpresaSelect();
  showToast(`Empresa "${name}" registrada`, 'fa-building', '#166534');
}
function renderEmpresas() {
  const rows = EMPRESAS.map(e => {
    const count = MATERIALES.filter(m => m.empresaId === e.id).length;
    return `<tr>
      <td class="fw-bold" style="font-size:13px">${e.name}</td>
      <td style="font-size:12px;color:var(--gray-400)">${e.contact || '—'}</td>
      <td class="mono fw-bold" style="text-align:center">${count}</td>
      <td>
        <div class="d-flex gap-1 flex-wrap">
          <button class="abtn" onclick="openEmpresaModal(${e.id})"><i class="fa-solid fa-address-card me-1"></i>Ficha</button>
          ${isAdmin() ? `<button class="abtn-red" onclick="askDeleteEmpresa(${e.id})"><i class="fa-solid fa-trash"></i></button>` : ''}
        </div>
      </td>
    </tr>`;
  }).join('');
  document.getElementById('emp-tbl').innerHTML = `
    <thead><tr><th>Empresa</th><th>Contacto</th><th>Materiales entregados</th><th></th></tr></thead>
    <tbody>${rows || '<tr><td colspan="4" style="text-align:center;color:var(--gray-400);padding:20px">Sin empresas registradas</td></tr>'}</tbody>`;
}
function verMaterialesDeEmpresa(id) {
  const modal = bootstrap.Modal.getInstance(document.getElementById('empModal')); if (modal) modal.hide();
  go('materiales');
  setTimeout(() => { document.getElementById('ms-inp').value = empresaNombre(id); filterMats(); }, 50);
}
function agregarMaterialDeEmpresa(id) {
  const modal = bootstrap.Modal.getInstance(document.getElementById('empModal')); if (modal) modal.hide();
  go('materiales');
  setTimeout(() => { fillEmpresaSelect(); document.getElementById('nm-empresa').value = id; document.getElementById('nm-nombre').focus(); }, 50);
}
function openEmpresaModal(id) {
  const e = EMPRESAS.find(x => x.id === id); if (!e) return;
  const mats = MATERIALES.filter(m => m.empresaId === id);
  const disp = mats.filter(m => m.estado === 'DISPONIBLE').length;
  const prest = mats.filter(m => m.estado === 'PRESTADO').length;
  const uso = mats.filter(m => m.estado === 'EN_USO').length;
  const rep = mats.filter(m => m.estado === 'REPARACION').length;
  const moves = [];
  mats.forEach(m => m.historial.forEach(h => moves.push({ ...h, matId: m.id })));
  moves.sort((a, b) => b.fecha.localeCompare(a.fecha));
  document.getElementById('emp-modal-body').innerHTML = `
    <div class="d-flex align-items-start justify-content-between mb-3">
      <div>
        <div class="cond fw-bold" style="font-size:20px;color:var(--red)">${e.name}</div>
        <div style="font-size:12px;color:var(--gray-400)">${e.contact || 'Sin contacto registrado'}</div>
      </div>
      <button type="button" class="ibtn" data-bs-dismiss="modal">✕</button>
    </div>
    <div class="modal-data-grid mb-3">
      <div style="background:var(--red-muted);border-radius:10px;padding:12px"><div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase">Registrados</div><div class="fw-bold" style="font-size:18px">${mats.length}</div></div>
      <div style="background:#f0fdf4;border-radius:10px;padding:12px"><div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase">Disponibles</div><div class="fw-bold" style="font-size:18px">${disp}</div></div>
      <div style="background:#eff6ff;border-radius:10px;padding:12px"><div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase">Prestados</div><div class="fw-bold" style="font-size:18px">${prest}</div></div>
      <div style="background:#fef3c7;border-radius:10px;padding:12px"><div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase">En uso</div><div class="fw-bold" style="font-size:18px">${uso}</div></div>
    </div>
    ${rep ? `<div style="font-size:12px;color:#9a3412;margin-bottom:10px"><i class="fa-solid fa-screwdriver-wrench me-1"></i>${rep} material(es) en reparación</div>` : ''}
    <div class="slbl">Últimos movimientos de sus materiales</div>
    <div style="max-height:180px;overflow:auto;margin-bottom:14px">
      ${moves.slice(0, 6).map(h => `<div style="font-size:12px;padding:5px 0;border-bottom:1px solid var(--gray-100)"><span class="mono fw-bold" style="color:var(--red)">${h.matId}</span> — ${h.tipo}: ${h.de} → ${h.para} <span class="mono" style="color:var(--gray-400);font-size:10px">(${h.fecha})</span></div>`).join('') || '<div style="font-size:12px;color:var(--gray-400)">Sin movimientos aún</div>'}
    </div>
    <div class="d-flex gap-2 flex-wrap">
      <button class="abtn" onclick="verMaterialesDeEmpresa(${e.id})"><i class="fa-solid fa-boxes-stacked me-1"></i>Ver materiales</button>
      ${canEdit() ? `<button class="abtn-green" onclick="agregarMaterialDeEmpresa(${e.id})"><i class="fa-solid fa-plus me-1"></i>Agregar material</button>
      <button class="abtn-blue" onclick="editarEmpresa(${e.id})"><i class="fa-solid fa-pen me-1"></i>Editar empresa</button>` : ''}
    </div>`;
  new bootstrap.Modal(document.getElementById('empModal')).show();
}
function editarEmpresa(id) {
  if (!canEdit()) { requirePermission('Tu rol no puede editar empresas.'); return; }
  const e = EMPRESAS.find(x => x.id === id); if (!e) return;
  const name = prompt('Nombre de la empresa:', e.name);
  if (name === null) return;
  const contact = prompt('Contacto:', e.contact || '');
  if (name.trim()) e.name = name.trim();
  e.contact = (contact || '').trim();
  MATERIALES.forEach(m => { if (m.empresaId === id) m.empresaNombre = e.name; });
  renderEmpresas(); openEmpresaModal(id);
  showToast('Empresa actualizada', 'fa-pen', '#1d4ed8');
}
function registrarResponsable() {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede registrar responsables.'); return; }
  const nombre = document.getElementById('nr-nombre').value.trim();
  const nota = document.getElementById('nr-nota').value.trim();
  if (!nombre) { showToast('Ingresa el nombre del responsable', 'fa-triangle-exclamation', '#d97706'); return; }
  if (RESPONSABLES.find(r => r.nombre.toLowerCase() === nombre.toLowerCase())) { showToast('Ese responsable ya existe', 'fa-triangle-exclamation', '#d97706'); return; }
  RESPONSABLES.push({ id: nextRespId++, nombre, nota });
  document.getElementById('nr-nombre').value = ''; document.getElementById('nr-nota').value = '';
  renderResponsables(); fillDatalists();
  showToast(`Responsable "${nombre}" registrado`, 'fa-user-plus', '#166534');
}
function renderResponsables() {
  const rows = RESPONSABLES.map(r => {
    const mats = MATERIALES.filter(m => m.custodioActual === r.nombre);
    return `<tr>
      <td class="fw-bold" style="font-size:13px">${r.nombre}</td>
      <td style="font-size:12px;color:var(--gray-400)">${r.nota || '—'}</td>
      <td class="mono fw-bold" style="text-align:center">${mats.length}</td>
      <td><button class="abtn" onclick="verMaterialesDeResponsable('${r.nombre.replace(/'/g,"\\'")}')"><i class="fa-solid fa-boxes-stacked me-1"></i>Ver materiales</button></td>
    </tr>`;
  }).join('');
  document.getElementById('resp-tbl').innerHTML = `
    <thead><tr><th>Nombre</th><th>Nota</th><th>Materiales a su cargo</th><th></th></tr></thead>
    <tbody>${rows || '<tr><td colspan="4" style="text-align:center;color:var(--gray-400);padding:20px">Sin responsables registrados</td></tr>'}</tbody>`;
}
function verMaterialesDeResponsable(nombre) {
  go('materiales');
  setTimeout(() => { document.getElementById('ms-inp').value = nombre; filterMats(); }, 50);
}
let mF = 'all', mS = '';
function registrarMaterial() {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede registrar materiales.'); return; }
  const nombre = document.getElementById('nm-nombre').value.trim();
  const empresaId = Number(document.getElementById('nm-empresa').value);
  const resp = document.getElementById('nm-resp').value.trim();
  const ubicacion = document.getElementById('nm-ubicacion').value.trim();
  const estado = document.getElementById('nm-estado').value;
  const cantidad = Math.max(1, parseInt(document.getElementById('nm-cantidad').value, 10) || 1);
  const errEl = document.getElementById('nm-err'); errEl.style.display = 'none';
  if (!nombre) { errEl.textContent = '⚠️ Ingresa el nombre del material.'; errEl.style.display = 'block'; return; }
  if (!empresaId) { errEl.textContent = '⚠️ Registra o selecciona una empresa.'; errEl.style.display = 'block'; return; }
  if (!resp) { errEl.textContent = '⚠️ Ingresa quién recibe el material.'; errEl.style.display = 'block'; return; }
  const nombreEmpresa = empresaNombre(empresaId);
  const loteId = cantidad > 1 ? `LOTE-${String(nextLoteId++).padStart(4, '0')}` : null;
  const nuevos = [];
  for (let i = 0; i < cantidad; i++) {
    const num = String(nextMatId++).padStart(4, '0');
    const id = `MAT-${num}`;
    const codigo = generarCodigoQR(nombre, i); // ej: 15304582A00 (HHMMSS + MS + letra + consecutivo)
    const notaRecepcion = cantidad > 1 ? `Recepción inicial — parte de un lote de ${cantidad} unidades` : 'Recepción inicial del material';
    const item = {
      id, codigo, nombre, empresaId, empresaNombre: nombreEmpresa, loteId, estado, custodioActual: resp,
      ubicacion, descripcion: nombre, observaciones: '', prestamo: null,
      historial: [{ tipo: 'Recepción', de: nombreEmpresa, para: resp, fecha: nowStr(), nota: notaRecepcion, por: currentUser?.name || 'Sistema', estado: 'RECIBIDO' }]
    };
    MATERIALES.unshift(item);
    nuevos.push(item);
  }
  addResponsableIfNew(resp);
  document.getElementById('nm-nombre').value = ''; document.getElementById('nm-resp').value = ''; document.getElementById('nm-ubicacion').value = ''; document.getElementById('nm-cantidad').value = '1';
  renderMats(); initOverview(); fillDatalists();
  if (cantidad > 1) {
    showToast(`${cantidad} unidades de "${nombre}" registradas en ${loteId}`, 'fa-qrcode', '#166534');
    openLoteModal(loteId);
  } else {
    showToast(`${nuevos[0].id} registrado — QR generado`, 'fa-qrcode', '#166534');
    openMatModal(nuevos[0].id);
  }
}
function setMF(f, el) { mF = f; document.querySelectorAll('#mat-chips .chip').forEach(c => c.classList.remove('on')); el.classList.add('on'); renderMats(); }
function filterMats() { mS = document.getElementById('ms-inp').value.toLowerCase(); renderMats(); }
function renderMats() {
  const list = MATERIALES.filter(m => {
    const mf = mF === 'all' || m.estado === mF;
    const ms = !mS || [m.id, m.codigo, m.nombre, matEmpresaNombre(m), m.custodioActual, estLabel(m.estado), m.ubicacion || ''].some(v => v.toLowerCase().includes(mS));
    return mf && ms;
  });
  document.getElementById('mc').textContent = `${list.length}/${MATERIALES.length}`;
  document.getElementById('mat-tbl').innerHTML = `
    <thead><tr><th>QR</th><th>ID</th><th>Material</th><th>Empresa</th><th>Responsable</th><th>Estado</th><th>Ubicación</th><th>Últ. mov.</th><th></th></tr></thead>
    <tbody>${list.map(matRow).join('') || '<tr><td colspan="9" style="text-align:center;color:var(--gray-400);padding:20px">Sin resultados</td></tr>'}</tbody>`;
}
function buildBreadcrumb(m) {
  const chain = [matEmpresaNombre(m), ...m.historial.map(h => h.para)];
  const unique = [];
  chain.forEach(c => { if (unique[unique.length - 1] !== c) unique.push(c); });
  return unique.join(' → ');
}
const ACCIONES = [
  { tipo: 'Prestamo',   label: 'Prestar',             cls: 'abtn-blue',   icon: 'fa-people-arrows',        estados: ['DISPONIBLE','DEVUELTO','RECIBIDO'] },
  { tipo: 'Devolver',   label: 'Devolver',            cls: 'abtn-green',  icon: 'fa-rotate-left',          estados: ['PRESTADO','EN_USO','TRANSFERIDO'] },
  { tipo: 'Transferir', label: 'Transferir',          cls: 'abtn-purple', icon: 'fa-right-left',           estados: ['PRESTADO','EN_USO','DISPONIBLE','TRANSFERIDO'] },
  { tipo: 'EnUso',      label: 'Marcar en uso',       cls: 'abtn-green',  icon: 'fa-screwdriver-wrench',   estados: ['DISPONIBLE','PRESTADO','DEVUELTO','RECIBIDO'] },
  { tipo: 'Disponible', label: 'Marcar disponible',   cls: 'abtn',        icon: 'fa-circle-check',         estados: ['EN_USO','DEVUELTO','REPARACION','PRESTADO','TRANSFERIDO'] },
  { tipo: 'Reparacion', label: 'Enviar a reparación', cls: 'abtn',        icon: 'fa-wrench',                estados: ['DISPONIBLE','EN_USO','DAÑADO'] },
  { tipo: 'Danado',     label: 'Reportar dañado',     cls: 'abtn-red',    icon: 'fa-triangle-exclamation', estados: ['DISPONIBLE','EN_USO','PRESTADO','REPARACION'] },
  { tipo: 'Extraviado', label: 'Reportar extraviado', cls: 'abtn-red',    icon: 'fa-magnifying-glass',     estados: ['PRESTADO','EN_USO','TRANSFERIDO'] },
  { tipo: 'Baja',       label: 'Dar de baja',         cls: 'abtn-red',    icon: 'fa-ban',                   estados: ['DAÑADO','EXTRAVIADO','REPARACION','DISPONIBLE'] },
];
function accionesPara(m) { return ACCIONES.filter(a => a.estados.includes(m.estado)); }
function openMatModal(id) {
  const m = MATERIALES.find(x => x.id === id); if (!m) return;
  const qrId = 'qr-' + m.id;
  const loteInfo = m.loteId ? loteResumen(m.loteId) : null;
  const vencido = isVencido(m);
  const acciones = accionesPara(m);
  document.getElementById('mat-modal-body').innerHTML = `
    <div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
      <div>
        <div class="cond fw-bold" style="font-size:20px;color:var(--red);letter-spacing:.04em">${m.id} — ${m.nombre}</div>
        <div style="font-size:12px;color:var(--gray-400)">Código: <span class="qrt">${m.codigo}</span>
          ${loteInfo ? ` &nbsp;|&nbsp; Parte de <a href="#" onclick="event.preventDefault();openLoteModal('${m.loteId}')" style="color:var(--red);font-weight:700">${m.loteId}</a> (${loteInfo.total} unidades)` : ''}
        </div>
      </div>
      <button type="button" class="ibtn flex-shrink-0" data-bs-dismiss="modal">✕</button>
    </div>
    ${vencido ? `<div class="mb-3" style="background:#fee2e2;border:1px solid #fecaca;border-radius:9px;padding:9px 13px;font-size:12px;color:#b91c1c;font-weight:700"><i class="fa-solid fa-clock me-1"></i>Préstamo vencido — se esperaba de vuelta el ${m.prestamo.fechaEsperada}</div>` : ''}
    <div class="row g-3 mb-3">
      <div class="col-12 col-md-5 text-center">
        <div id="${qrId}" style="display:inline-block;padding:10px;background:#fff;border:1px solid var(--gray-200);border-radius:10px"></div>
        <div style="font-size:10px;color:var(--gray-400);margin-top:6px">Símbolo QR — ISO/IEC 18004</div>
        <div style="font-size:9.5px;color:var(--gray-400);margin-top:2px" title="Hora(HHMMSS) + Milisegundos(00-99) + Letra de producto + Consecutivo">
          ${m.codigo.slice(0,6)}<b style="color:var(--red-dark)">·${m.codigo.slice(6,8)}</b>·<b style="color:var(--red-dark)">${m.codigo.slice(8)}</b> = hora·ms·producto
        </div>
        <div class="d-flex gap-2 justify-content-center mt-2">
          <button class="abtn" onclick="imprimirQR('${m.id}')"><i class="fa-solid fa-print me-1"></i>Imprimir</button>
          <button class="abtn" onclick="imprimirQR('${m.id}')"><i class="fa-solid fa-arrows-rotate me-1"></i>Reimprimir</button>
        </div>
      </div>
      <div class="col-12 col-md-7">
        <div class="modal-data-grid">
          <div style="background:var(--red-muted);border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Empresa de origen</div>
            <div class="fw-bold" style="font-size:13px">${matEmpresaNombre(m)}</div>
          </div>
          <div style="background:var(--gray-100);border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Responsable actual</div>
            <div class="fw-bold" style="font-size:13px">${m.custodioActual}</div>
          </div>
          <div style="background:#dcfce7;border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Estado</div>
            <div class="fw-bold" style="font-size:13px">${sb(m.estado)}</div>
          </div>
          <div style="background:#eff6ff;border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Ubicación</div>
            <div class="fw-bold" style="font-size:13px">${m.ubicacion || '—'}</div>
          </div>
          <div style="background:var(--gray-50);border:1px solid var(--gray-200);border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Fecha de ingreso</div>
            <div class="fw-bold mono" style="font-size:12px">${m.historial[0].fecha}</div>
          </div>
          <div style="background:var(--gray-50);border:1px solid var(--gray-200);border-radius:10px;padding:12px">
            <div style="font-size:9px;color:var(--gray-400);font-weight:800;text-transform:uppercase;letter-spacing:.1em">Último movimiento</div>
            <div class="fw-bold mono" style="font-size:12px">${m.historial[m.historial.length - 1].fecha}</div>
          </div>
        </div>
        <div class="mt-3" style="font-size:12px;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:9px;padding:10px 12px">
          <strong>Trazabilidad:</strong> ${buildBreadcrumb(m)}
        </div>
        ${m.observaciones ? `<div class="mt-2" style="font-size:12px;color:var(--gray-700)"><strong>Observaciones:</strong> ${m.observaciones}</div>` : ''}
      </div>
    </div>
    <div class="slbl">Historial de movimientos</div>
    <div class="tl mb-3">
      ${m.historial.map((h, i) => `
        <div class="tl-item">
          <div class="tl-dot ${i === m.historial.length - 1 ? 'cu' : 'dn'}"></div>
          <div class="fw-bold" style="font-size:13px">${h.tipo}: ${h.de} → ${h.para}</div>
          <div class="mono" style="font-size:11px;color:var(--gray-400);margin-top:1px">${h.fecha} — registrado por ${h.por || '—'}</div>
          ${h.nota ? `<div style="font-size:12px;color:var(--gray-700);font-style:italic;margin-top:2px">"${h.nota}"</div>` : ''}
        </div>`).join('')}
    </div>
    <div class="d-flex gap-2 flex-wrap">
      ${canEdit() ? acciones.map(a => `<button class="${a.cls}" onclick="bootstrap.Modal.getInstance(document.getElementById('matModal')).hide();openMovModal('${m.id}','${a.tipo}')"><i class="fa-solid ${a.icon} me-1"></i>${a.label}</button>`).join('')
        : `<div style="font-size:12px;color:var(--gray-400)"><i class="fa-solid fa-lock me-1"></i>Tu rol (Visualizador) solo puede consultar este historial.</div>`}
    </div>`;
  const modal = new bootstrap.Modal(document.getElementById('matModal'));
  modal.show();
  setTimeout(() => {
    const box = document.getElementById(qrId);
    if (box && window.QRCode) { box.innerHTML = ''; new QRCode(box, { text: m.codigo, width: 150, height: 150, correctLevel: QRCode.CorrectLevel.M }); }
  }, 60);
}
function imprimirQR(id) {
  const m = MATERIALES.find(x => x.id === id); if (!m) return;
  const w = window.open('', '_blank', 'width=420,height=520');
  w.document.write(`<html><head><title>QR ${m.id}</title></head><body style="font-family:sans-serif;text-align:center;padding:20px">
    <div id="qr"></div>
    <h3 style="margin-top:14px">${m.nombre}</h3>
    <div style="font-family:monospace;font-size:13px">${m.codigo}</div>
    <div style="font-size:10px;color:#888;margin-top:4px">Símbolo QR — ISO/IEC 18004</div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"><\/script>
    <script>new QRCode(document.getElementById('qr'), {text:'${m.codigo}', width:220, height:220});setTimeout(()=>window.print(),300);<\/script>
  </body></html>`);
  w.document.close();
}
function abrirGeneracionMasiva() {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede generar etiquetas.'); return; }
  document.getElementById('masivo-modal-body').innerHTML = `
    <div class="d-flex align-items-center justify-content-between mb-3">
      <div class="fw-bold" style="font-size:15px">Generar varios QR a la vez</div>
      <button type="button" class="ibtn" data-bs-dismiss="modal">✕</button>
    </div>
    <input type="text" class="tp-inp mb-2" placeholder="Buscar material..." oninput="filtrarMasivo(this.value)">
    <div id="masivo-lista" style="max-height:320px;overflow:auto;border:1px solid var(--gray-200);border-radius:9px;padding:8px"></div>
    <button class="abtn-green w-100 mt-3" style="padding:11px" onclick="imprimirMasivo()"><i class="fa-solid fa-print me-1"></i>Imprimir seleccionados</button>`;
  filtrarMasivo('');
  new bootstrap.Modal(document.getElementById('masivoModal')).show();
}
function filtrarMasivo(q) {
  q = q.toLowerCase();
  const list = MATERIALES.filter(m => !q || [m.id, m.codigo, m.nombre].some(v => v.toLowerCase().includes(q)));
  document.getElementById('masivo-lista').innerHTML = list.map(m => `
    <label class="d-flex align-items-center gap-2" style="font-size:12px;padding:5px 4px;border-bottom:1px solid var(--gray-100)">
      <input type="checkbox" class="masivo-chk" value="${m.id}">
      <span class="mono fw-bold" style="color:var(--red)">${m.id}</span> ${m.nombre}
    </label>`).join('') || '<div style="font-size:12px;color:var(--gray-400);padding:8px">Sin resultados</div>';
}
function imprimirMasivo() {
  const ids = Array.from(document.querySelectorAll('.masivo-chk:checked')).map(c => c.value);
  if (!ids.length) { showToast('Selecciona al menos un material', 'fa-triangle-exclamation', '#d97706'); return; }
  const items = MATERIALES.filter(m => ids.includes(m.id));
  const w = window.open('', '_blank', 'width=600,height=700');
  w.document.write(`<html><head><title>Etiquetas QR</title></head><body style="font-family:sans-serif;padding:10px">
    <div style="display:flex;flex-wrap:wrap;gap:14px">
      ${items.map(m => `<div style="text-align:center;border:1px solid #ddd;border-radius:8px;padding:10px;width:150px">
        <div id="qr-${m.id}"></div>
        <div style="font-weight:bold;font-size:12px;margin-top:6px">${m.nombre}</div>
        <div style="font-family:monospace;font-size:11px">${m.codigo}</div>
      </div>`).join('')}
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"><\/script>
    <script>
      ${items.map(m => `new QRCode(document.getElementById('qr-${m.id}'), {text:'${m.codigo}', width:110, height:110});`).join('\n')}
      setTimeout(()=>window.print(),400);
    <\/script>
  </body></html>`);
  w.document.close();
  bootstrap.Modal.getInstance(document.getElementById('masivoModal'))?.hide();
}
const MOV_TITLES = { Prestamo: 'Prestar', Devolver: 'Devolver', Transferir: 'Transferir', EnUso: 'Marcar en uso', Disponible: 'Marcar disponible', Reparacion: 'Enviar a reparación', Danado: 'Reportar dañado', Extraviado: 'Reportar extraviado', Baja: 'Dar de baja' };
function openMovModal(id, tipo) {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede registrar movimientos.'); return; }
  const m = MATERIALES.find(x => x.id === id); if (!m) return;
  let fields = '';
  if (tipo === 'Prestamo') {
    fields = `
      <div class="fm-group"><label class="fm-label">Material</label><input class="tp-inp" value="${m.nombre}" disabled></div>
      <div class="fm-group"><label class="fm-label">Actualmente con</label><input class="tp-inp" value="${m.custodioActual}" disabled></div>
      <div class="fm-group"><label class="fm-label">Prestar a</label><input id="mov-para" class="tp-inp" placeholder="Ej: David" list="resp-list"></div>
      <div class="row g-2">
        <div class="col-6"><div class="fm-group"><label class="fm-label">Fecha</label><input id="mov-fecha" type="date" class="tp-inp" value="${todayISO()}"></div></div>
        <div class="col-6"><div class="fm-group"><label class="fm-label">Hora</label><input id="mov-hora" type="time" class="tp-inp" value="${nowHM()}"></div></div>
      </div>
      <div class="fm-group"><label class="fm-label">Fecha esperada de devolución (opcional)</label><input id="mov-fecha-esp" type="date" class="tp-inp"></div>
      <div class="fm-group"><label class="fm-label">Motivo</label><input id="mov-motivo" class="tp-inp" placeholder="Ej: Uso en prototipo"></div>`;
  } else if (tipo === 'Devolver') {
    fields = `
      <div class="fm-group"><label class="fm-label">Material</label><input class="tp-inp" value="${m.nombre}" disabled></div>
      <div class="fm-group"><label class="fm-label">Actualmente con</label><input class="tp-inp" value="${m.custodioActual}" disabled></div>
      <div class="fm-group"><label class="fm-label">Devolver a</label><input id="mov-para" class="tp-inp" placeholder="Ej: Pepe" list="resp-list" value="${m.historial.filter(h=>h.tipo==='Préstamo'||h.tipo==='Recepción').slice(-1)[0]?.de || ''}"></div>
      <div class="fm-group"><label class="fm-label">Estado</label>
        <select id="mov-estado-extra" class="fm-select"><option value="Funcionando">Funcionando</option><option value="Con falla">Con falla</option></select></div>
      <div class="fm-group"><label class="fm-label">Observación</label><input id="mov-motivo" class="tp-inp" placeholder="Ej: Se utilizó en prototipo"></div>`;
  } else if (tipo === 'Transferir') {
    fields = `
      <div class="fm-group"><label class="fm-label">Material</label><input class="tp-inp" value="${m.nombre}" disabled></div>
      <div class="fm-group"><label class="fm-label">Actualmente con</label><input class="tp-inp" value="${m.custodioActual}" disabled></div>
      <div class="fm-group"><label class="fm-label">Transferir a</label><input id="mov-para" class="tp-inp" placeholder="Ej: Luis" list="resp-list"></div>
      <div class="row g-2">
        <div class="col-6"><div class="fm-group"><label class="fm-label">Fecha</label><input id="mov-fecha" type="date" class="tp-inp" value="${todayISO()}"></div></div>
        <div class="col-6"><div class="fm-group"><label class="fm-label">Hora</label><input id="mov-hora" type="time" class="tp-inp" value="${nowHM()}"></div></div>
      </div>
      <div class="fm-group"><label class="fm-label">Motivo</label><input id="mov-motivo" class="tp-inp" placeholder="Motivo de la transferencia"></div>`;
  } else if (tipo === 'EnUso') {
    fields = `
      <div class="fm-group"><label class="fm-label">Usado en / instalado en</label><input id="mov-para" class="tp-inp" placeholder="Ej: Prototipo Estación Meteorológica" value="${m.custodioActual}"></div>
      <div class="fm-group"><label class="fm-label">Observación</label><input id="mov-motivo" class="tp-inp" placeholder="Ej: Instalado y funcionando"></div>`;
  } else {
    // Disponible, Reparacion, Danado, Extraviado, Baja: solo observación
    fields = `
      <div class="fm-group"><label class="fm-label">Material</label><input class="tp-inp" value="${m.nombre}" disabled></div>
      <div class="fm-group"><label class="fm-label">Observación</label><input id="mov-motivo" class="tp-inp" placeholder="Detalle (opcional)"></div>`;
  }
  document.getElementById('mov-modal-body').innerHTML = `
    <div class="d-flex align-items-center justify-content-between mb-3">
      <div><div class="fw-bold" style="font-size:15px">${MOV_TITLES[tipo]} — ${m.id}</div><div style="font-size:12px;color:var(--gray-400)">${m.nombre}</div></div>
      <button type="button" class="ibtn" data-bs-dismiss="modal">✕</button>
    </div>
    ${fields}
    <div id="mov-err" style="font-size:12px;color:var(--red);font-weight:700;margin-bottom:10px;display:none"></div>
    <button class="abtn-green w-100" style="padding:11px" onclick="guardarMovimiento('${m.id}','${tipo}')"><i class="fa-solid fa-floppy-disk me-1"></i>Confirmar ${MOV_TITLES[tipo].toLowerCase()}</button>`;
  new bootstrap.Modal(document.getElementById('movModal')).show();
}
function guardarMovimiento(id, tipo) {
  if (!canEdit()) { requirePermission('Tu rol (Visualizador) no puede registrar movimientos.'); return; }
  const m = MATERIALES.find(x => x.id === id); if (!m) return;
  const errEl = document.getElementById('mov-err'); errEl.style.display = 'none';
  const motivo = document.getElementById('mov-motivo')?.value.trim() || '';
  const de = m.custodioActual;
  const fecha = document.getElementById('mov-fecha') && document.getElementById('mov-hora')
    ? `${document.getElementById('mov-fecha').value} ${document.getElementById('mov-hora').value}` : nowStr();
  if (tipo === 'Prestamo') {
    const para = document.getElementById('mov-para').value.trim();
    if (!para) { errEl.textContent = '⚠️ Indica a quién se presta.'; errEl.style.display = 'block'; return; }
    const fechaEsp = document.getElementById('mov-fecha-esp').value || null;
    m.historial.push({ tipo: 'Préstamo', de, para, fecha, nota: motivo, por: currentUser?.name || 'Sistema', estado: 'PRESTADO' });
    m.custodioActual = para; m.estado = 'PRESTADO'; m.prestamo = fechaEsp ? { fechaEsperada: fechaEsp } : null;
    addResponsableIfNew(para);
  } else if (tipo === 'Devolver') {
    const para = document.getElementById('mov-para').value.trim();
    const estadoExtra = document.getElementById('mov-estado-extra').value;
    if (!para) { errEl.textContent = '⚠️ Indica a quién se devuelve.'; errEl.style.display = 'block'; return; }
    m.historial.push({ tipo: 'Devolución', de, para, fecha, nota: motivo || `Devuelto — ${estadoExtra}`, por: currentUser?.name || 'Sistema', estado: 'DEVUELTO' });
    m.custodioActual = para; m.estado = estadoExtra === 'Con falla' ? 'DAÑADO' : 'DEVUELTO'; m.prestamo = null;
    addResponsableIfNew(para);
  } else if (tipo === 'Transferir') {
    const para = document.getElementById('mov-para').value.trim();
    if (!para) { errEl.textContent = '⚠️ Indica a quién se transfiere.'; errEl.style.display = 'block'; return; }
    m.historial.push({ tipo: 'Transferencia', de, para, fecha, nota: motivo, por: currentUser?.name || 'Sistema', estado: 'TRANSFERIDO' });
    m.custodioActual = para; m.estado = 'TRANSFERIDO';
    addResponsableIfNew(para);
  } else if (tipo === 'EnUso') {
    const para = document.getElementById('mov-para').value.trim() || de;
    m.historial.push({ tipo: 'Uso', de, para, fecha, nota: motivo || 'Instalado y funcionando', por: currentUser?.name || 'Sistema', estado: 'EN_USO' });
    m.estado = 'EN_USO';
  } else if (tipo === 'Disponible') {
    m.historial.push({ tipo: 'Estado', de: estLabel(m.estado), para: 'Disponible', fecha, nota: motivo || 'Marcado como disponible', por: currentUser?.name || 'Sistema', estado: 'DISPONIBLE' });
    m.estado = 'DISPONIBLE'; m.prestamo = null;
  } else if (tipo === 'Reparacion') {
    m.historial.push({ tipo: 'Estado', de: estLabel(m.estado), para: 'Reparación', fecha, nota: motivo || 'Enviado a reparación', por: currentUser?.name || 'Sistema', estado: 'REPARACION' });
    m.estado = 'REPARACION';
  } else if (tipo === 'Danado') {
    m.historial.push({ tipo: 'Estado', de: estLabel(m.estado), para: 'Dañado', fecha, nota: motivo || 'Reportado como dañado', por: currentUser?.name || 'Sistema', estado: 'DAÑADO' });
    m.estado = 'DAÑADO';
  } else if (tipo === 'Extraviado') {
    m.historial.push({ tipo: 'Estado', de: estLabel(m.estado), para: 'Extraviado', fecha, nota: motivo || 'Reportado como extraviado', por: currentUser?.name || 'Sistema', estado: 'EXTRAVIADO' });
    m.estado = 'EXTRAVIADO';
  } else if (tipo === 'Baja') {
    m.historial.push({ tipo: 'Estado', de: estLabel(m.estado), para: 'Baja', fecha, nota: motivo || 'Dado de baja', por: currentUser?.name || 'Sistema', estado: 'BAJA' });
    m.estado = 'BAJA';
  }
  bootstrap.Modal.getInstance(document.getElementById('movModal')).hide();
  renderMats(); initOverview(); fillDatalists();
  showToast(`${id}: ${MOV_TITLES[tipo].toLowerCase()} registrado`, 'fa-arrow-right-arrow-left', '#1d4ed8');
}
let scanStream = null, scanRAF = null;
async function startScan() {
  const video = document.getElementById('scan-video');
  const frame = document.getElementById('scan-frame');
  try {
    scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
    video.srcObject = scanStream;
    await video.play();
    frame.style.display = 'block';
    scanLoop();
  } catch (err) {
    showToast('No se pudo acceder a la cámara', 'fa-triangle-exclamation', '#b91c1c');
  }
}
function stopScan() {
  if (scanRAF) cancelAnimationFrame(scanRAF);
  if (scanStream) { scanStream.getTracks().forEach(t => t.stop()); scanStream = null; }
  const frame = document.getElementById('scan-frame'); if (frame) frame.style.display = 'none';
}
function scanLoop() {
  const video = document.getElementById('scan-video');
  const canvas = document.getElementById('scan-canvas');
  if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) { scanRAF = requestAnimationFrame(scanLoop); return; }
  canvas.width = video.videoWidth; canvas.height = video.videoHeight;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const code = window.jsQR ? jsQR(img.data, img.width, img.height) : null;
  if (code && code.data) { onCodeDetected(code.data); stopScan(); return; }
  scanRAF = requestAnimationFrame(scanLoop);
}
document.addEventListener('DOMContentLoaded', () => {
  const inp = document.getElementById('scan-manual');
  if (inp) inp.addEventListener('keydown', e => {
    if (e.key === 'Enter' && inp.value.trim()) { onCodeDetected(inp.value.trim()); inp.value = ''; }
  });
});
function onCodeDetected(code) {
  const clean = code.trim();
  const m = MATERIALES.find(x => x.codigo.toLowerCase() === clean.toLowerCase() || x.id.toLowerCase() === clean.toLowerCase());
  const box = document.getElementById('scan-result');
  if (!m) {
    box.innerHTML = `<div class="scan-result-card" style="border-color:#fecaca;background:#fef2f2">
      <div class="fw-bold" style="color:#b91c1c"><i class="fa-solid fa-circle-xmark me-1"></i>Código no encontrado</div>
      <div style="font-size:12px;color:var(--gray-400);margin-top:4px">No existe ningún material con el código "${clean}".</div>
    </div>`;
    showToast('Código no reconocido', 'fa-triangle-exclamation', '#b91c1c');
    return;
  }
  const acciones = accionesPara(m).filter(a => ['Prestamo','Devolver','Transferir','EnUso'].includes(a.tipo)).slice(0, 3);
  box.innerHTML = `
    <div class="scan-result-card">
      <div class="d-flex align-items-center justify-content-between mb-2">
        <div>
          <div class="mono fw-bold" style="color:var(--red);font-size:13px">${m.id}</div>
          <div class="fw-bold" style="font-size:16px">${m.nombre}</div>
        </div>
        ${sb(m.estado)}
      </div>
      <div style="font-size:12px;color:var(--gray-600);margin-bottom:8px">
        <i class="fa-solid fa-building me-1"></i>${matEmpresaNombre(m)} &nbsp;|&nbsp;
        <i class="fa-solid fa-user me-1"></i>Con: <strong>${m.custodioActual}</strong> &nbsp;|&nbsp;
        <i class="fa-solid fa-location-dot me-1"></i>${m.ubicacion || '—'}
      </div>
      <div style="font-size:12px;background:#fff;border:1px solid var(--gray-200);border-radius:8px;padding:8px 10px;margin-bottom:10px">
        <strong>Trazabilidad:</strong> ${buildBreadcrumb(m)}
      </div>
      ${canEdit() ? `<div class="d-flex flex-column gap-2 mb-2">
        ${acciones.map(a => `<button class="scan-quick-btn ${a.cls}" onclick="openMovModal('${m.id}','${a.tipo}')"><i class="fa-solid ${a.icon}"></i>${a.label.toUpperCase()}</button>`).join('')}
      </div>` : ''}
      <button class="abtn w-100" onclick="openMatModal('${m.id}')"><i class="fa-solid fa-timeline me-1"></i>Ver historial completo</button>
    </div>`;
  showToast(`${m.id} — información cargada`, 'fa-qrcode', '#166534');
}
let usFilter = '', pendingDelId = null, pendingDelType = 'user', bsDelModal = null, bsUserModal = null;
function renderUsers() {
  const q = usFilter.toLowerCase();
  const list = q ? USERS.filter(u => u.username.toLowerCase().includes(q) || u.name.toLowerCase().includes(q) || u.role.includes(q)) : USERS;
  document.getElementById('us-total').textContent = USERS.length;
  document.getElementById('us-active').textContent = USERS.filter(u => u.status === 'activo').length;
  document.getElementById('us-admins').textContent = USERS.filter(u => u.role === 'admin').length;
  document.getElementById('us-inactive').textContent = USERS.filter(u => u.status === 'inactivo').length;
  const rows = list.map(u => {
    const [bg, cl] = AVATAR_COLORS[u.id % AVATAR_COLORS.length];
    const isMe = currentUser && u.id === currentUser.id;
    return `<tr>
      <td><div class="d-flex align-items-center gap-2">
        <div class="user-row-avatar" style="background:${bg};color:${cl}">${u.name.charAt(0)}</div>
        <div><div class="fw-bold" style="font-size:13px">${u.name}${isMe ? ` <span class="mono" style="font-size:9px;color:var(--gray-400)">[TÚ]</span>` : ''}</div>
          <div class="mono" style="font-size:11px;color:var(--gray-400)">${u.username}</div></div>
      </div></td>
      <td><span class="sb ${ROLE_CLASSES[u.role]}">${ROLE_ICONS[u.role]} ${ROLE_LABELS[u.role]}</span></td>
      <td><span class="sb ${u.status === 'activo' ? 'user-status-on' : 'user-status-off'}">${u.status === 'activo' ? '✅ Activo' : '❌ Inactivo'}</span></td>
      <td class="mono" style="font-size:11px;color:var(--gray-400)">${u.created}</td>
      <td><div class="d-flex gap-1 flex-wrap">
        <button class="abtn" onclick="openUserModal(${u.id})"><i class="fa-solid fa-pen me-1"></i>Editar</button>
        ${!isMe ? `<button class="abtn-red" onclick="askDelete(${u.id})"><i class="fa-solid fa-trash"></i></button>` : ''}
      </div></td>
    </tr>`;
  }).join('');
  document.getElementById('users-tbl').innerHTML = `
    <thead><tr><th>Usuario</th><th>Rol</th><th>Estado</th><th>Creado</th><th>Acciones</th></tr></thead>
    <tbody>${rows || '<tr><td colspan="5" style="text-align:center;color:var(--gray-400);padding:20px">Sin resultados</td></tr>'}</tbody>`;
}
function filterUsers() { usFilter = document.getElementById('us-search').value; renderUsers(); }
function openUserModal(id) {
  if (!isAdmin()) { requirePermission('Solo un administrador puede gestionar usuarios.'); return; }
  if (!bsUserModal) bsUserModal = new bootstrap.Modal(document.getElementById('userModal'));
  ['um-name', 'um-user', 'um-pass'].forEach(i => { const el = document.getElementById(i); if (el) el.value = ''; });
  document.getElementById('um-id').value = ''; document.getElementById('um-role').value = 'operador'; document.getElementById('um-status').value = 'activo';
  document.getElementById('um-err').style.display = 'none';
  document.getElementById('um-pass-hint').textContent = '(requerida para nuevo usuario)';
  if (id) {
    const u = USERS.find(x => x.id === id); if (!u) return;
    document.getElementById('um-title').textContent = 'Editar usuario';
    document.getElementById('um-id').value = u.id; document.getElementById('um-name').value = u.name;
    document.getElementById('um-user').value = u.username; document.getElementById('um-role').value = u.role; document.getElementById('um-status').value = u.status;
    document.getElementById('um-pass-hint').textContent = '(dejar vacío para no cambiar)';
  } else { document.getElementById('um-title').textContent = 'Nuevo usuario'; }
  bsUserModal.show();
}
function saveUser() {
  if (!isAdmin()) { requirePermission('Solo un administrador puede gestionar usuarios.'); return; }
  const id = document.getElementById('um-id').value;
  const name = document.getElementById('um-name').value.trim();
  const uname = document.getElementById('um-user').value.trim();
  const pass = document.getElementById('um-pass').value;
  const role = document.getElementById('um-role').value;
  const status = document.getElementById('um-status').value;
  const errEl = document.getElementById('um-err'); errEl.style.display = 'none';
  if (!name) { errEl.textContent = '⚠️ El nombre es requerido.'; errEl.style.display = 'block'; return; }
  if (!uname) { errEl.textContent = '⚠️ El usuario es requerido.'; errEl.style.display = 'block'; return; }
  const dup = USERS.find(u => u.username === uname && String(u.id) !== id);
  if (dup) { errEl.textContent = '⚠️ Ese nombre de usuario ya existe.'; errEl.style.display = 'block'; return; }
  if (id) {
    const u = USERS.find(x => x.id === Number(id)); if (!u) return;
    u.name = name; u.username = uname; u.role = role; u.status = status;
    if (pass) { if (pass.length < 6) { errEl.textContent = '⚠️ Mínimo 6 caracteres.'; errEl.style.display = 'block'; return; } u.password = pass; }
    showToast(`Usuario ${uname} actualizado`, 'fa-pen', '#1d4ed8');
    if (currentUser && u.id === currentUser.id) { currentUser = u; applyRoleUI(); }
  } else {
    if (!pass) { errEl.textContent = '⚠️ La contraseña es requerida.'; errEl.style.display = 'block'; return; }
    if (pass.length < 6) { errEl.textContent = '⚠️ Mínimo 6 caracteres.'; errEl.style.display = 'block'; return; }
    const today = new Date();
    const d = `${today.getFullYear()}-${p2(today.getMonth()+1)}-${p2(today.getDate())}`;
    USERS.push({ id: nextId++, name, username: uname, password: pass, role, status, created: d });
    showToast(`Usuario ${uname} creado`, 'fa-user-plus', '#166534');
  }
  bsUserModal.hide(); renderUsers();
}
function askDelete(id) {
  if (!isAdmin()) { requirePermission('Solo un administrador puede gestionar usuarios.'); return; }
  pendingDelId = id; pendingDelType = 'user';
  const u = USERS.find(x => x.id === id);
  document.getElementById('del-name').textContent = `¿Seguro que deseas eliminar a "${u?.name}"? Esta acción no se puede deshacer.`;
  if (!bsDelModal) bsDelModal = new bootstrap.Modal(document.getElementById('delModal'));
  bsDelModal.show();
}
function askDeleteEmpresa(id) {
  if (!isAdmin()) { requirePermission('Solo un administrador puede eliminar empresas.'); return; }
  pendingDelId = id; pendingDelType = 'empresa';
  const e = EMPRESAS.find(x => x.id === id);
  const count = MATERIALES.filter(m => m.empresaId === id).length;
  document.getElementById('del-name').textContent = count
    ? `¿Eliminar "${e?.name}"? Tiene ${count} material(es) registrados: seguirán existiendo con "${e?.name}" guardado en su historial, pero la empresa dejará de aparecer en el listado activo.`
    : `¿Seguro que deseas eliminar la empresa "${e?.name}"? Esta acción no se puede deshacer.`;
  if (!bsDelModal) bsDelModal = new bootstrap.Modal(document.getElementById('delModal'));
  bsDelModal.show();
}
function confirmDelete() {
  if (!pendingDelId) return;
  if (pendingDelType === 'empresa') {
    const e = EMPRESAS.find(x => x.id === pendingDelId);
    EMPRESAS = EMPRESAS.filter(x => x.id !== pendingDelId);
    pendingDelId = null; bsDelModal.hide();
    showToast(`Empresa "${e?.name}" eliminada — sus materiales conservan el historial`, 'fa-trash', '#b91c1c');
    renderEmpresas(); fillEmpresaSelect(); initOverview(); renderMats();
  } else {
    const u = USERS.find(x => x.id === pendingDelId);
    USERS = USERS.filter(x => x.id !== pendingDelId);
    pendingDelId = null; bsDelModal.hide();
    showToast(`Usuario ${u?.username} eliminado`, 'fa-trash', '#b91c1c');
    renderUsers();
  }
}

TrackProd Web Platform – CSS Interface

CSS
This code implements the visual design of the TrackProd web platform.

It defines the layout, navigation, dashboards, tables, forms, status indicators, and responsive interface.

The styles provide a clear visual representation of products, system states, traceability information, and management tools.

The interface is designed to provide operators and administrators with an intuitive industrial monitoring environment.
:root{--red:#c0161a;--red-dark:#8b0f12;--red-light:#e8292d;--red-muted:#f5e0e0;--gray-900:#1a1a1a;--gray-700:#333333;--gray-600:#555555;--gray-400:#999999;--gray-200:#e8e8e8;--gray-100:#f4f4f4;--gray-50:#fafafa;--white:#ffffff;--side:#141414;--side-border:rgba(255, 255, 255, .06);--green:#15803d;--amber:#b45309;--blue:#1d4ed8;--teal:#0d9488;--card-shadow:0 1px 3px rgba(0, 0, 0, .08),0 4px 16px rgba(0, 0, 0, .04)}
*{box-sizing:border-box}
body{font-family:Barlow,sans-serif;background:var(--gray-100);color:var(--gray-900);overflow-x:hidden;margin:0}
.mono{font-family:'JetBrains Mono',monospace}
.cond{font-family:'Barlow Condensed',sans-serif}
#login-screen{min-height:100vh;background:var(--gray-900);display:flex;align-items:center;justify-content:center;padding:20px;position:relative;overflow:hidden}
#login-screen::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 80% 60% at 15% 60%,rgba(192,22,26,.22) 0,transparent 65%),radial-gradient(ellipse 50% 40% at 85% 20%,rgba(139,15,18,.15) 0,transparent 60%)}
.login-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:44px 40px;width:100%;max-width:440px;backdrop-filter:blur(24px);position:relative;z-index:1;box-shadow:0 32px 80px rgba(0,0,0,.5),0 0 0 1px rgba(192,22,26,.2);animation:loginSlide .45s cubic-bezier(.22,1,.36,1)}
@keyframes loginSlide{
from{opacity:0;transform:translateY(24px)}
to{opacity:1;transform:translateY(0)}
}
.login-logo-wrap{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:28px}
.login-logo-icon{width:56px;height:56px;background:linear-gradient(135deg,var(--red-light),var(--red-dark));border-radius:14px;display:grid;place-items:center;color:#fff;font-size:22px;box-shadow:0 0 32px rgba(192,22,26,.5),inset 0 1px 0 rgba(255,255,255,.15)}
.login-brand{text-align:left}
.login-brand-top{font-family:'Barlow Condensed',sans-serif;font-size:11px;font-weight:700;letter-spacing:.2em;text-transform:uppercase;color:#666;line-height:1}
.login-brand-name{font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;letter-spacing:.03em;color:var(--white);line-height:1}
.login-brand-name span{color:var(--red-light)}
.login-sub{font-size:13px;color:#555;text-align:center;margin-bottom:28px;font-weight:500}
.lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#666;margin-bottom:6px;display:block}
.lin{width:100%;background:rgba(255,255,255,.05);border:1.5px solid rgba(255,255,255,.1);border-radius:10px;padding:12px 14px;font-size:14px;font-family:Barlow,sans-serif;color:var(--white);outline:0;transition:border-color .15s,background .15s}
.lin:focus{border-color:var(--red);background:rgba(192,22,26,.08)}
.lin::placeholder{color:#444}
.lin-wrap{position:relative}
.lin-icon{position:absolute;left:13px;top:50%;transform:translateY(-50%);color:#444;font-size:13px;pointer-events:none}
.lin.has-icon{padding-left:38px}
.btn-login{width:100%;background:linear-gradient(135deg,var(--red-light),var(--red-dark));color:#fff;border:none;border-radius:10px;padding:13px;font-size:14px;font-weight:800;font-family:'Barlow Condensed',sans-serif;letter-spacing:.06em;text-transform:uppercase;cursor:pointer;transition:all .15s;box-shadow:0 4px 20px rgba(192,22,26,.4);margin-top:8px}
.btn-login:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(192,22,26,.5)}
.login-err{background:rgba(192,22,26,.15);border:1px solid rgba(192,22,26,.3);border-radius:9px;padding:9px 13px;font-size:12px;font-weight:700;color:#f87171;display:none;margin-top:10px}
.demo-hint{font-size:11px;color:#444;text-align:center;margin-top:20px;border-top:1px solid rgba(255,255,255,.06);padding-top:14px}
.login-check{display:flex;align-items:center;gap:8px;font-size:12px;color:#999;font-weight:600;margin-top:6px;cursor:pointer;user-select:none}
.login-check input{width:15px;height:15px;accent-color:var(--red);cursor:pointer}
.demo-chip{display:inline-block;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:3px 9px;font-family:'JetBrains Mono',monospace;font-size:11px;color:#888;margin:2px;cursor:pointer;transition:all .15s}
.demo-chip:hover{background:rgba(192,22,26,.2);color:#fca5a5;border-color:var(--red)}
#sidebar{width:230px;height:100vh;position:sticky;top:0;background:var(--side);display:flex;flex-direction:column;flex-shrink:0;border-right:1px solid var(--side-border);overflow:hidden}
#sidebar nav{overflow-y:auto;overflow-x:hidden;flex:1;min-height:0;padding:6px 0}
.side-logo{padding:20px 18px 16px;border-bottom:1px solid var(--side-border);position:relative}
.side-logo-icon{width:36px;height:36px;background:linear-gradient(135deg,var(--red-light),var(--red-dark));border-radius:9px;display:grid;place-items:center;color:#fff;font-size:16px;flex-shrink:0;box-shadow:0 2px 12px rgba(192,22,26,.4)}
.side-brand-name{font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:var(--white);letter-spacing:.04em;line-height:1}
.side-brand-name span{color:var(--red-light)}
.side-brand-sub{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.12em;font-weight:600;margin-top:2px}
.nav-sec{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#444;font-weight:800;padding:14px 16px 4px}
.nav-link-tp{display:flex;align-items:center;gap:10px;padding:9px 14px;margin:2px 8px;border-radius:8px;cursor:pointer;color:#888;font-size:13px;font-weight:600;text-decoration:none;transition:background .15s,color .15s;white-space:nowrap}
.nav-link-tp:hover{background:rgba(255,255,255,.06);color:#e0e0e0}
.nav-link-tp.active{background:rgba(192,22,26,.2);color:#ff8080;border-left:2px solid var(--red);margin-left:6px;padding-left:12px}
.nav-link-tp.active i{color:var(--red-light)}
.nav-link-tp i{width:16px;text-align:center;flex-shrink:0}
.n-badge{margin-left:auto;font-size:10px;font-weight:800;padding:1px 7px;border-radius:20px;font-family:'JetBrains Mono',monospace}
.side-footer{margin-top:auto;padding:14px 12px;border-top:1px solid var(--side-border)}
.side-user{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;background:rgba(255,255,255,.04);margin-bottom:8px;cursor:pointer;transition:background .15s}
.side-user:hover{background:rgba(255,255,255,.08)}
.side-avatar{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:13px;flex-shrink:0}
#topbar{background:var(--white);border-bottom:1px solid var(--gray-200);padding:0 20px;height:56px;display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:100;box-shadow:var(--card-shadow)}
.tb-titles{flex:1;min-width:0;overflow:hidden}
.tb-title{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--gray-900);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-sub{font-size:11px;color:var(--gray-400);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-clock{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--red);background:var(--red-muted);padding:4px 10px;border-radius:7px;font-weight:700;white-space:nowrap;flex-shrink:0}
.ibtn{width:36px;height:36px;flex-shrink:0;background:var(--gray-100);border:1px solid var(--gray-200);border-radius:8px;cursor:pointer;color:var(--gray-600);display:grid;place-items:center;font-size:13px;transition:all .15s}
.ibtn:hover{background:var(--red-muted);color:var(--red);border-color:rgba(192,22,26,.3)}
.role-badge{padding:3px 10px;border-radius:20px;font-size:10px;font-weight:800;font-family:'JetBrains Mono',monospace;white-space:nowrap;letter-spacing:.05em}
#main-wrap{flex:1;min-width:0;max-width:100%;display:flex;flex-direction:column;overflow:hidden}
#page-content{flex:1;overflow-x:hidden;overflow-y:auto;min-width:0}
.view{display:none;padding:20px}
.view.active{display:block;animation:fadeup .2s ease}
@keyframes fadeup{
from{opacity:0;transform:translateY(6px)}
to{opacity:1;transform:translateY(0)}
}
.tp-card{background:var(--white);border:1px solid var(--gray-200);border-radius:14px;padding:18px;box-shadow:var(--card-shadow)}
.top-accent{border-top:3px solid}
.slbl{font-family:'Barlow Condensed',sans-serif;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--gray-400);font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.slbl::after{content:'';flex:1;height:1px;background:var(--gray-200)}
.stat-icon{width:40px;height:40px;border-radius:10px;display:grid;place-items:center;font-size:16px;flex-shrink:0}
.stat-val{font-family:'Barlow Condensed',sans-serif;font-size:32px;font-weight:800;line-height:1;letter-spacing:-.01em}
.stat-lbl{font-size:11px;color:var(--gray-400);font-weight:700;text-transform:uppercase;letter-spacing:.08em}
.kpi-card{background:var(--white);border:1px solid var(--gray-200);border-radius:14px;padding:18px 20px;display:flex;align-items:center;gap:14px;box-shadow:var(--card-shadow);position:relative;overflow:hidden}
.kpi-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
.kpi-card.kpi-red::before{background:var(--red)}
.kpi-card.kpi-green::before{background:var(--green)}
.kpi-card.kpi-amber::before{background:#d97706}
.kpi-card.kpi-gray::before{background:var(--gray-400)}
.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.tbl{width:100%;border-collapse:collapse;min-width:500px}
.tbl th{font-family:'Barlow Condensed',sans-serif;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--gray-400);padding:10px 14px;border-bottom:2px solid var(--gray-200);font-weight:700;text-align:left;white-space:nowrap;background:var(--gray-50)}
.tbl td{padding:11px 14px;border-bottom:1px solid #f0f0f0;font-size:13px;vertical-align:middle}
.tbl tbody tr:hover td{background:#fafafa}
.tbl tbody tr:last-child td{border:none}
.sb{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;font-family:'JetBrains Mono',monospace;white-space:nowrap}
.sb::before{content:'';width:6px;height:6px;border-radius:50%;flex-shrink:0}
.s-disp{background:#f0fdf4;color:#166534}
.s-disp::before{background:#22c55e}
.s-prest{background:#eff6ff;color:#1d4ed8}
.s-prest::before{background:#3b82f6}
.s-uso{background:#fef3c7;color:#92400e}
.s-uso::before{background:#d97706}
.s-dan{background:#fee2e2;color:#b91c1c}
.s-dan::before{background:#ef4444}
.s-baja{background:var(--gray-100);color:var(--gray-400)}
.s-baja::before{background:#ccc}
.s-rep{background:#fff7ed;color:#9a3412}
.s-rep::before{background:#f97316}
.s-ext{background:#fdf2f8;color:#9d174d}
.s-ext::before{background:#ec4899}
.s-transf{background:#f5f3ff;color:#5b21b6}
.s-transf::before{background:#8b5cf6}
.qrt{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--red-dark);background:var(--red-muted);padding:2px 8px;border-radius:5px;font-weight:700;display:inline-block}
.abtn{background:var(--red-muted);border:1.5px solid rgba(192,22,26,.25);color:var(--red-dark);padding:5px 12px;border-radius:7px;font-size:11px;cursor:pointer;font-weight:700;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap;transition:all .12s}
.abtn:hover{background:#f5c0c0}
.abtn-red{background:#fee2e2;border:1.5px solid #fecaca;color:#b91c1c;padding:5px 12px;border-radius:7px;font-size:11px;cursor:pointer;font-weight:700;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap}
.abtn-red:hover{background:#fecaca}
.abtn-green{background:#f0fdf4;border:1.5px solid #bbf7d0;color:#166534;padding:5px 12px;border-radius:7px;font-size:11px;cursor:pointer;font-weight:700;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap}
.abtn-green:hover{background:#dcfce7}
.abtn-blue{background:#eff6ff;border:1.5px solid #bfdbfe;color:#1d4ed8;padding:5px 12px;border-radius:7px;font-size:11px;cursor:pointer;font-weight:700;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap}
.abtn-blue:hover{background:#dbeafe}
.abtn-purple{background:#f5f3ff;border:1.5px solid #ddd6fe;color:#5b21b6;padding:5px 12px;border-radius:7px;font-size:11px;cursor:pointer;font-weight:700;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap}
.abtn-purple:hover{background:#ede9fe}
.tp-inp{width:100%;background:var(--gray-50);border:1.5px solid var(--gray-200);border-radius:9px;padding:10px 13px;font-size:14px;font-family:Barlow,sans-serif;color:var(--gray-900);outline:0;transition:border-color .15s,background .15s}
.tp-inp:focus{border-color:var(--red);background:#fff;box-shadow:0 0 0 3px rgba(192,22,26,.08)}
.tp-inp::placeholder{color:#aaa}
.fm-group{margin-bottom:14px}
.fm-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--gray-400);margin-bottom:5px;display:block}
.fm-select{width:100%;background:var(--gray-50);border:1.5px solid var(--gray-200);border-radius:9px;padding:10px 13px;font-size:14px;font-family:Barlow,sans-serif;color:var(--gray-900);outline:0;appearance:none;cursor:pointer;transition:border-color .15s}
.fm-select:focus{border-color:var(--red)}
.chip{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:20px;font-size:12px;font-weight:700;border:1.5px solid var(--gray-200);cursor:pointer;background:var(--gray-50);color:var(--gray-400);transition:all .15s;white-space:nowrap}
.chip.on,.chip:hover{background:var(--red-muted);color:var(--red-dark);border-color:rgba(192,22,26,.3)}
.modal-content{border-radius:16px;border:none;box-shadow:0 24px 80px rgba(0,0,0,.15)}
.modal-data-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@media (max-width:480px){
.modal-data-grid{grid-template-columns:1fr}
}
.tl{position:relative;padding-left:24px}
.tl::before{content:'';position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--gray-200)}
.tl-item{position:relative;margin-bottom:18px}
.tl-dot{position:absolute;left:-24px;top:4px;width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 2px var(--gray-200)}
.tl-dot.dn{background:var(--green);box-shadow:0 0 0 2px #bbf7d0}
.tl-dot.cu{background:var(--red);box-shadow:0 0 0 2px rgba(192,22,26,.3)}
.live-dot{display:inline-block;width:7px;height:7px;background:#22c55e;border-radius:50%;animation:blink 1.4s infinite}
@keyframes blink{
0%,100%{opacity:1}
50%{opacity:.3}
}
@keyframes spin{
from{transform:rotate(0)}
to{transform:rotate(360deg)}
}
.user-row-avatar{width:32px;height:32px;border-radius:8px;display:inline-grid;place-items:center;font-weight:800;font-size:12px}
.role-admin{background:rgba(192,22,26,.15);color:var(--red-dark)}
.role-op{background:#dcfce7;color:#166534}
.role-viewer{background:var(--gray-100);color:var(--gray-600)}
.user-status-on{background:#dcfce7;color:#166534}
.user-status-off{background:#fee2e2;color:#b91c1c}
#toast-box{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px}
.toast-msg{background:var(--gray-900);color:#f5f5f5;border-radius:10px;padding:12px 18px;font-size:13px;font-weight:600;box-shadow:0 8px 24px rgba(0,0,0,.3);display:flex;align-items:center;gap:9px;animation:toastIn .3s cubic-bezier(.22,1,.36,1);min-width:220px}
@keyframes toastIn{
from{opacity:0;transform:translateY(10px)}
to{opacity:1;transform:translateY(0)}
}
#bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;background:var(--white);border-top:1px solid var(--gray-200);z-index:200;height:60px;box-shadow:0 -4px 16px rgba(0,0,0,.08)}
.bn-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;cursor:pointer;color:var(--gray-400);font-size:10px;font-weight:700;padding:4px 0;border:none;background:0 0;text-decoration:none;transition:color .15s}
.bn-item.active,.bn-item:hover{color:var(--red)}
.bn-item i{font-size:18px}
#scan-video-wrap{position:relative;width:100%;max-width:420px;margin:0 auto;border-radius:14px;overflow:hidden;background:#000}
#scan-video{width:100%;display:block}
#scan-frame{position:absolute;inset:12%;border:3px solid var(--red-light);border-radius:12px;box-shadow:0 0 0 2000px rgba(0,0,0,.35);pointer-events:none}
.scan-result-card{border:1.5px solid var(--gray-200);border-radius:12px;padding:14px;background:var(--gray-50)}
.scan-quick-btn{width:100%;padding:16px;border-radius:12px;font-size:14px;font-weight:800;font-family:'Barlow Condensed',sans-serif;letter-spacing:.05em;text-transform:uppercase;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px}
#qr-print-box{padding:18px;text-align:center}
@media print{
body *{visibility:hidden}
#qr-print-box,#qr-print-box *{visibility:visible}
#qr-print-box{position:absolute;top:0;left:0;width:100%}
}
@media (min-width:1200px){
#bottom-nav{display:none!important}
#topbar-logout{display:none!important}
}
@media (min-width:992px) and (max-width:1199.98px){
#bottom-nav{display:none!important}
#sidebar{width:190px}
}
@media (min-width:768px) and (max-width:991.98px){
#bottom-nav{display:none!important}
#sidebar{width:58px;overflow:hidden}
#sidebar .side-logo{padding:12px 10px}
#sidebar .side-logo-icon{margin:0 auto}
#sidebar #sb-clk,#sidebar .n-badge,#sidebar .nav-link-tp span,#sidebar .nav-sec,#sidebar .side-brand-name,#sidebar .side-brand-sub,#sidebar .side-footer .d-flex span,#sidebar .side-footer .side-user>div,#sidebar .side-footer .side-user>i{display:none!important}
#sidebar .nav-link-tp{justify-content:center;padding:10px 0;margin:2px 4px}
#sidebar .nav-link-tp.active{border-left:none;margin-left:2px;padding-left:0}
}
@media (max-width:767.98px){
#sidebar{display:none!important}
#bottom-nav{display:flex}
#page-content{padding-bottom:70px}
.view{padding:10px}
.tp-card{padding:14px}
}
@media (max-width:479.98px){
.tb-clock{display:none!important}
.view{padding:8px}
.tp-card{padding:12px;border-radius:10px}
.login-card{padding:28px 20px}
}
@media (max-width:991.98px){
#topbar-logout{display:grid!important}
}
@media (min-width:992px){
#topbar-logout{display:none!important}
}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-thumb{background:var(--gray-200);border-radius:2px}

TrackProd Web Platform – HTML Structure

HTML
This code implements the main HTML structure of the TrackProd web platform.

It defines the interfaces for the dashboard, products, materials, users, traceability records, and QR identification.

The different sections organize the information generated by the TrackProd IIoT system into an accessible web interface.

This structure serves as the foundation of the TrackProd monitoring and management platform
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TrackProd - IIoT QR Traceability</title>
</head>
<body>

<div id="login-screen">
  <div class="login-card">
    <h2><i class="fa-solid fa-qrcode"></i> TrackProd</h2>
    <p>IIoT QR Traceability System</p>
    <input id="l-user" class="tp-inp" placeholder="User">
    <input id="l-pass" type="password" class="tp-inp" placeholder="Password">
    <button class="btn-login" onclick="doLogin()">Login</button>
    <div id="l-err" class="login-err"></div>
  </div>
</div>

<div id="app-screen" style="display:none">
  <nav id="sidebar">
    <h4>TrackProd</h4>
    <a onclick="go('overview')">Dashboard</a>
    <a onclick="go('materiales')">Materials</a>
    <a onclick="go('empresas')">Companies</a>
    <a onclick="go('responsables')">Responsible</a>
    <a onclick="go('escanear')">Scan QR</a>
    <a onclick="go('users')">Users</a>
  </nav>

  <main id="main-wrap">
    <header id="topbar">
      <h3 id="tb-t">Dashboard</h3>
      <span id="top-clk">--:--:--</span>
    </header>

    <section class="view active" id="view-overview">
      <div class="row g-2">
        <div class="col"><div class="kpi-card">Materials <b id="k-total">0</b></div></div>
        <div class="col"><div class="kpi-card">Available <b id="k-disp">0</b></div></div>
        <div class="col"><div class="kpi-card">In use <b id="k-prest">0</b></div></div>
        <div class="col"><div class="kpi-card">Repair <b id="k-rep">0</b></div></div>
      </div>
      <div class="tp-card mt-3"><h5>Recent activity</h5><div id="dash-feed"></div></div>
    </section>

    <section class="view" id="view-materiales">
      <h4>Materials</h4>
      <input id="nm-nombre" class="tp-inp" placeholder="Material name">
      <select id="nm-empresa" class="fm-select"></select>
      <input id="nm-resp" class="tp-inp" placeholder="Responsible">
      <input id="nm-ubicacion" class="tp-inp" placeholder="Location">
      <button onclick="registrarMaterial()">Register and generate QR</button>
      <input id="ms-inp" class="tp-inp" placeholder="Search" oninput="filterMats()">
      <table id="mat-tbl" class="tbl"></table>
    </section>

    <section class="view" id="view-empresas">
      <h4>Companies</h4>
      <input id="ne-nombre" class="tp-inp" placeholder="Company">
      <input id="ne-contacto" class="tp-inp" placeholder="Contact">
      <button onclick="registrarEmpresa()">Add</button>
      <table id="emp-tbl" class="tbl"></table>
    </section>

    <section class="view" id="view-responsables">
      <h4>Responsible users</h4>
      <input id="nr-nombre" class="tp-inp" placeholder="Name">
      <button onclick="registrarResponsable()">Add</button>
      <table id="resp-tbl" class="tbl"></table>
    </section>

    <section class="view" id="view-escanear">
      <h4>QR Scanner</h4>
      <video id="scan-video" playsinline muted></video>
      <canvas id="scan-canvas" style="display:none"></canvas>
      <button onclick="startScan()">Start camera</button>
      <button onclick="stopScan()">Stop</button>
      <input id="scan-manual" class="tp-inp" placeholder="QR code">
      <div id="scan-result"></div>
    </section>

    <section class="view" id="view-users">
      <h4>User management</h4>
      <button onclick="openUserModal()">New user</button>
      <input id="us-search" class="tp-inp" placeholder="Search users" oninput="filterUsers()">
      <table id="users-tbl" class="tbl"></table>
    </section>
  </main>
</div>

<div id="matModal" class="modal fade"><div class="modal-dialog"><div class="modal-content"><div id="mat-modal-body" class="modal-body"></div></div></div></div>
<div id="movModal" class="modal fade"><div class="modal-dialog"><div class="modal-content"><div id="mov-modal-body" class="modal-body"></div></div></div></div>
<div id="userModal" class="modal fade"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><input id="um-name" class="tp-inp" placeholder="Name"><input id="um-user" class="tp-inp" placeholder="User"><input id="um-pass" class="tp-inp" type="password" placeholder="Password"><button onclick="saveUser()">Save</button></div></div></div></div>

<div id="toast-box"></div>
</body>
</html>

Credits

David Patricio Escamiilla Marquez
1 project • 0 followers

Comments