Hardware components | ||||||
![]() |
| × | 1 | |||
![]() |
| × | 4 | |||
![]() |
| × | 1 | |||
![]() |
| × | 1 | |||
| × | 5 | ||||
![]() |
| × | 1 | |||
![]() |
| × | 1 | |||
Software apps and online services | ||||||
![]() |
| |||||
| ||||||
| ||||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
![]() |
| |||||
TrackProd is a smart Industrial IoT (IIoT) system designed to bring real-time traceability to manufacturing processes in small and medium-sized enterprises (SMEs).
This project is being developed as part of a Master’s Degree in Engineering thesis at the Tecnológico Nacional de México, Instituto Tecnológico de Nuevo León, located in Monterrey, Mexico.
Many SMEs still rely on manual processes for tracking products, which can lead to errors, lack of visibility, and inefficiencies in production. During the analysis of an industrial environment, it was identified that there was no proper system to monitor products across different stages, causing delays, inventory issues, and limited process visibility.
To address this problem, TrackProd was designed as a low-cost, modular, and scalable Industry 4.0 solution.
TrackProd is designed to assign a unique QR code to each product, allowing it to be identified and tracked throughout the production process. As a product moves through different stages, such as cutting, painting, quality control, and dispatch, its events can be registered and associated with its digital traceability record.
The system is designed to integrate multiple industrial and communication technologies, including QR identification, LoRa wireless communication, an industrial signal tower (stack light), and a Nextion HMI display for local interaction and status visualization.
In addition to the hardware layer, TrackProd includes a web-based platform developed with HTML, CSS, JavaScript, and backend technologies. The platform acts as the central visualization and management interface for product information, process stages, timestamps, status, and historical traceability records.
This architecture connects physical industrial devices with digital monitoring, allowing information to be transmitted, stored in a database, and visualized through a centralized web platform.
At the core of the prototype is the Arduino UNO Q, which provides the processing, connectivity, and flexibility required to integrate the different hardware and software layers of the system.
Because TrackProd is part of a two-year Master’s Degree research project, its development will continue beyond the current prototype. A future development stage will incorporate an AI Agent designed to automate the digital traceability workflow. The objective is for the operator to only identify the product through its QR code and select the next production process, while the AI Agent manages tasks such as updating the product status, recording timestamps, maintaining the traceability history, monitoring process conditions, and generating alerts.
As TrackProd collects historical production data, the AI Agent will also provide a foundation for anomaly detection, process-time analysis, predictive analytics, and intelligent decision support.
TrackProd demonstrates how Arduino UNO Q, IIoT, QR identification, LoRa communication, industrial signaling, HMI interfaces, database technologies, and web-based monitoring can be combined to create a scalable and affordable traceability platform for SMEs, while establishing the foundation for future AI-driven automation and intelligent industrial traceability
Prototype development
After TrackProd was selected for the “Invent the Future with Arduino UNO Q and App Lab” contest, the project entered the prototype implementation stage, using the Arduino UNO Q as the main processing and integration platform.
Development proceeded incrementally, integrating hardware, wireless communication, local interaction, industrial signaling, and the web platform.
Arduino UNO Q and App Lab
The arrival of the Arduino UNO Q enabled the first hardware and software integration tests.
Development was carried out using Arduino App Lab, Arduino, and Python, exploring the UNO Q’s ability to combine physical device control with applications and services running on its Linux environment.
These tests established the Arduino UNO Q as the processing and integration core of TrackProd, connecting the physical and digital layers of the system.
Nextion HMI
To provide a local interface for the operator, a 4.3-inch Nextion HMI display was incorporated.
The interface allows system information and status to be viewed directly at the workstation, complementing the information available through the TrackProd web platform.
LoRa Communication
For wireless communication between TrackProd nodes, experimental tests were performed using LoRa SX1278 modules operating at 433 MHz.
LoRa provides the communication layer between distributed TrackProd nodes and the main node. Traceability information generated at different processes can be transmitted wirelessly to the main node, where it can be processed, stored in the database, and visualized through the web platform.
During the experimental tests, 532 packets were transmitted, 529 were successfully received, and only 3 were lost.
PDR: 99.44%
PER: 0.56%
Experimental LoRa communication results obtained during TrackProd prototype testing.
These results demonstrated the feasibility of using LoRa to transmit information between TrackProd nodes under the evaluated prototype conditions.
Industrial signaling
TrackProd also incorporates a relay-controlled industrial signal tower and buzzer to provide immediate visual and audible feedback about different system states
Signaling allows an operator to quickly recognize a system condition without relying solely on the HMI or the web platform.
TrackProd Web PlatformIn parallel with the hardware development, the TrackProd web platform was developed to centralize traceability information.
The first version was used to validate the initial concept and visualize basic system information.
The platform was later redesigned and expanded to include product and material management, users, searches, status monitoring, historical records, and movement tracking.
The web platform acts as the digital management layer of TrackProd, connecting physical IIoT events with information that can be stored, visualized, and used to follow the history of products and materials.
Identification via QR codes
QR-based identification is a key part of the TrackProd architecture. Each product or material is assigned a unique QR identifier linked to its digital information and traceability history.
The final architecture is designed to use a UART-based QR scanner for automatic identification. However, the physical QR scanner did not arrive in time for the contest submission, so its hardware integration could not be completed.
To demonstrate the QR identification workflow in the current prototype, a smartphone is temporarily used to scan the QR codes. This validates the connection between a physical QR identifier and its corresponding information in the TrackProd platform.
Once the dedicated QR reader is integrated, it will communicate directly with the embedded system through UART, allowing the identification process to become part of the automated TrackProd workflow.
TrackProd’s modular architecture allows this component to be incorporated without redesigning the complete system.
AI Agent for Automated TraceabilityThe next development stage of TrackProd is the integration of an AI Agent designed to automate the digital traceability workflow.
The objective is to minimize manual interaction. Instead of requiring the operator to manually register every movement, status change, timestamp, and traceability event, the operator would only need to scan the product QR code and select the next production process.
The proposed workflow is:
The AI Agent is designed to interact with the Arduino UNO Q, Python services, database, LoRa nodes, industrial signaling, and web platform.
As TrackProd collects historical production data, this architecture can also support future functions such as anomaly detection, process-time estimation, intelligent alerts, predictive analysis, and decision support.
The objective is for the operator to focus on the physical production process while the AI Agent progressively automates the corresponding digital traceability workflow.
Modular and scalable design
TrackProd was designed with a modular and scalable architecture, allowing new nodes, devices, sensors, production stages, and software functions to be progressively incorporated.
This enables an SME to begin with a small implementation and later expand the system to additional workstations or production stages according to its requirements.
Current status of the prototype
The current TrackProd prototype integrates the main IIoT layers developed so far:
Arduino UNO Q + App Lab → Nextion HMI → LoRa Communication → Industrial Signaling → Data Processing and Storage → Web Platform
QR identification is currently demonstrated through smartphone scanning, while the dedicated UART reader remains pending physical integration.
The prototype demonstrates the connection between embedded hardware, wireless communication, industrial interfaces, data processing, and web-based traceability, establishing the technological foundation required for the future AI Agent.
Operational Demonstration
The following video shows the current state of TrackProd's development and the integration of the key functions implemented so far.
The actual TrackProd prototype, hardware implementation, and experimental results are documented throughout this project.
Conclusion
TrackProd demonstrates how Arduino UNO Q, Arduino App Lab, Industrial IoT, LoRa communication, QR identification, HMI interfaces, industrial signaling, database technologies, and web-based monitoring can be integrated into a modular traceability platform for SMEs.
Experimental LoRa testing achieved a 99.44% Packet Delivery Ratio, while the current prototype demonstrates the integration of the physical and digital layers required for product and material traceability.
As part of the continued development of the Master’s Degree research project, the next stage is the integration of an AI Agent designed to reduce repetitive operator tasks. The long-term goal is for the operator to simply identify the product and select its next process, while TrackProd automatically manages and monitors the corresponding digital traceability workflow.
IIoT Hardware Architecture and Circuit Diagram
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.

TrackProd – Remote LoRa Node
C/C++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++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
PythonIt 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
JavaScriptIt 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 · ${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 ? ` | 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)} |
<i class="fa-solid fa-user me-1"></i>Con: <strong>${m.custodioActual}</strong> |
<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
CSSIt 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
HTMLIt 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>





















Comments