feat(herramientas): agregar soporte de fotografias y evidencias para herramientas, kits y asignaciones de custodia
This commit is contained in:
@@ -2,3 +2,5 @@ node_modules/
|
||||
.env
|
||||
servers.conf
|
||||
*.log
|
||||
public/uploads/herramientas/*
|
||||
!public/uploads/herramientas/.gitkeep
|
||||
|
||||
@@ -184,6 +184,7 @@ async function setup() {
|
||||
estado ENUM('operativo', 'en_mantenimiento', 'danado', 'baja') DEFAULT 'operativo',
|
||||
ubicacion_bodega VARCHAR(150) NULL,
|
||||
notas TEXT NULL,
|
||||
foto_url VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX (company_id),
|
||||
@@ -202,6 +203,7 @@ async function setup() {
|
||||
codigo VARCHAR(50) NULL,
|
||||
descripcion TEXT NULL,
|
||||
categoria VARCHAR(100) DEFAULT 'general',
|
||||
foto_url VARCHAR(255) NULL,
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -238,7 +240,9 @@ async function setup() {
|
||||
fecha_devolucion DATETIME NULL,
|
||||
estado ENUM('asignado', 'devuelto', 'observado') DEFAULT 'asignado',
|
||||
observaciones_entrega TEXT NULL,
|
||||
foto_entrega_url VARCHAR(255) NULL,
|
||||
observaciones_devolucion TEXT NULL,
|
||||
foto_devolucion_url VARCHAR(255) NULL,
|
||||
asignado_por INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -254,6 +258,12 @@ async function setup() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Migraciones de columnas para fotografías y evidencias
|
||||
await migrateColumn('herramientas', 'foto_url', 'foto_url VARCHAR(255) NULL AFTER notas');
|
||||
await migrateColumn('herramientas_sets', 'foto_url', 'foto_url VARCHAR(255) NULL AFTER descripcion');
|
||||
await migrateColumn('tecnico_herramientas_asignaciones', 'foto_entrega_url', 'foto_entrega_url VARCHAR(255) NULL AFTER observaciones_entrega');
|
||||
await migrateColumn('tecnico_herramientas_asignaciones', 'foto_devolucion_url', 'foto_devolucion_url VARCHAR(255) NULL AFTER observaciones_devolucion');
|
||||
|
||||
// Seed herramientas y sets iniciales para Telcotronics si no existen
|
||||
const [existingTools] = await conn.query('SELECT COUNT(*) as c FROM herramientas WHERE company_id = 1');
|
||||
if (existingTools[0].c === 0) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const UPLOADS_DIR = path.join(__dirname, '..', 'public', 'uploads', 'herramientas');
|
||||
|
||||
// Asegurar existencia del directorio de uploads
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif'
|
||||
];
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
||||
|
||||
/**
|
||||
* Guarda una imagen subida mediante express-fileupload en public/uploads/herramientas/
|
||||
* @param {Object} fileObj - Objeto de archivo de req.files
|
||||
* @param {string} prefix - Prefijo descriptivo del archivo (ej: 'herramienta', 'kit', 'entrega', 'devolucion')
|
||||
* @returns {Promise<string|null>} - Ruta pública (/uploads/herramientas/...) o null si no se proporcionó archivo
|
||||
*/
|
||||
async function guardarFoto(fileObj, prefix = 'herramienta') {
|
||||
if (!fileObj || !fileObj.name || !fileObj.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = path.extname(fileObj.name).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext) || !ALLOWED_MIME_TYPES.includes(fileObj.mimetype)) {
|
||||
throw new Error('Formato de imagen no soportado. Formatos válidos: JPG, PNG, WEBP, GIF.');
|
||||
}
|
||||
|
||||
// Límite de 8 MB
|
||||
if (fileObj.size > 8 * 1024 * 1024) {
|
||||
throw new Error('La imagen excede el límite máximo de 8MB.');
|
||||
}
|
||||
|
||||
const randomSuffix = crypto.randomBytes(6).toString('hex');
|
||||
const safeFilename = `${prefix}_${Date.now()}_${randomSuffix}${ext}`;
|
||||
const targetPath = path.join(UPLOADS_DIR, safeFilename);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
fileObj.mv(targetPath, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return `/uploads/herramientas/${safeFilename}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
guardarFoto,
|
||||
UPLOADS_DIR
|
||||
};
|
||||
@@ -43,14 +43,15 @@ class Herramienta {
|
||||
modelo = null,
|
||||
estado = 'operativo',
|
||||
ubicacion_bodega = null,
|
||||
notas = null
|
||||
notas = null,
|
||||
foto_url = null
|
||||
} = data;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO herramientas
|
||||
(company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas]
|
||||
(company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas, foto_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas, foto_url]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
@@ -59,7 +60,7 @@ class Herramienta {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
const allowed = ['nombre', 'codigo', 'numero_serie', 'categoria', 'marca', 'modelo', 'estado', 'ubicacion_bodega', 'notas'];
|
||||
const allowed = ['nombre', 'codigo', 'numero_serie', 'categoria', 'marca', 'modelo', 'estado', 'ubicacion_bodega', 'notas', 'foto_url'];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (allowed.includes(key)) {
|
||||
fields.push(`${key} = ?`);
|
||||
|
||||
@@ -8,8 +8,10 @@ class HerramientaAsignacion {
|
||||
u.email as tecnico_email,
|
||||
hs.nombre as set_nombre,
|
||||
hs.codigo as set_codigo,
|
||||
hs.foto_url as set_foto_url,
|
||||
h.nombre as herramienta_nombre,
|
||||
h.codigo as herramienta_codigo,
|
||||
h.foto_url as herramienta_foto_url,
|
||||
tv.titulo as visita_titulo,
|
||||
tv.ticket_id,
|
||||
t.asunto as ticket_asunto,
|
||||
@@ -53,10 +55,15 @@ class HerramientaAsignacion {
|
||||
u.nombre as tecnico_nombre,
|
||||
u.email as tecnico_email,
|
||||
hs.nombre as set_nombre,
|
||||
hs.codigo as set_codigo
|
||||
hs.codigo as set_codigo,
|
||||
hs.foto_url as set_foto_url,
|
||||
h.nombre as herramienta_nombre,
|
||||
h.codigo as herramienta_codigo,
|
||||
h.foto_url as herramienta_foto_url
|
||||
FROM tecnico_herramientas_asignaciones tha
|
||||
INNER JOIN users u ON tha.tecnico_id = u.id
|
||||
LEFT JOIN herramientas_sets hs ON tha.set_id = hs.id
|
||||
LEFT JOIN herramientas h ON tha.herramienta_id = h.id
|
||||
WHERE tha.id = ? AND tha.company_id = ?`,
|
||||
[id, companyId]
|
||||
);
|
||||
@@ -73,27 +80,35 @@ class HerramientaAsignacion {
|
||||
tipo_asignacion = 'dotacion_fija',
|
||||
fecha_asignacion = new Date(),
|
||||
observaciones_entrega = null,
|
||||
foto_entrega_url = null,
|
||||
asignado_por = null
|
||||
} = data;
|
||||
|
||||
const [res] = await db.query(
|
||||
`INSERT INTO tecnico_herramientas_asignaciones
|
||||
(company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, estado, observaciones_entrega, asignado_por)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'asignado', ?, ?)`,
|
||||
[company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, observaciones_entrega, asignado_por]
|
||||
(company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, estado, observaciones_entrega, foto_entrega_url, asignado_por)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'asignado', ?, ?, ?)`,
|
||||
[company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, observaciones_entrega, foto_entrega_url, asignado_por]
|
||||
);
|
||||
return res.insertId;
|
||||
}
|
||||
|
||||
static async devolver(id, companyId, { observaciones_devolucion = null, estado = 'devuelto' }) {
|
||||
await db.query(
|
||||
`UPDATE tecnico_herramientas_asignaciones
|
||||
SET estado = ?,
|
||||
fecha_devolucion = NOW(),
|
||||
observaciones_devolucion = ?
|
||||
WHERE id = ? AND company_id = ?`,
|
||||
[estado, observaciones_devolucion, id, companyId]
|
||||
);
|
||||
static async devolver(id, companyId, { observaciones_devolucion = null, estado = 'devuelto', foto_devolucion_url = null }) {
|
||||
let sql = `UPDATE tecnico_herramientas_asignaciones
|
||||
SET estado = ?,
|
||||
fecha_devolucion = NOW(),
|
||||
observaciones_devolucion = ?`;
|
||||
const params = [estado, observaciones_devolucion];
|
||||
|
||||
if (foto_devolucion_url) {
|
||||
sql += ', foto_devolucion_url = ?';
|
||||
params.push(foto_devolucion_url);
|
||||
}
|
||||
|
||||
sql += ' WHERE id = ? AND company_id = ?';
|
||||
params.push(id, companyId);
|
||||
|
||||
await db.query(sql, params);
|
||||
}
|
||||
|
||||
static async delete(id, companyId) {
|
||||
|
||||
@@ -50,13 +50,14 @@ class HerramientaSet {
|
||||
codigo = null,
|
||||
descripcion = null,
|
||||
categoria = 'general',
|
||||
foto_url = null,
|
||||
is_active = 1
|
||||
} = data;
|
||||
|
||||
const [res] = await db.query(
|
||||
`INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[company_id, nombre, codigo, descripcion, categoria, is_active]
|
||||
`INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria, foto_url, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[company_id, nombre, codigo, descripcion, categoria, foto_url, is_active]
|
||||
);
|
||||
const setId = res.insertId;
|
||||
|
||||
@@ -75,7 +76,7 @@ class HerramientaSet {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
const allowed = ['nombre', 'codigo', 'descripcion', 'categoria', 'is_active'];
|
||||
const allowed = ['nombre', 'codigo', 'descripcion', 'categoria', 'foto_url', 'is_active'];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (allowed.includes(key)) {
|
||||
fields.push(`${key} = ?`);
|
||||
|
||||
+44
-7
@@ -8,6 +8,7 @@ const Ticket = require('../models/Ticket');
|
||||
const Herramienta = require('../models/Herramienta');
|
||||
const HerramientaSet = require('../models/HerramientaSet');
|
||||
const HerramientaAsignacion = require('../models/HerramientaAsignacion');
|
||||
const { guardarFoto } = require('../config/uploader');
|
||||
|
||||
router.get('/dashboard', isCompanyAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -192,6 +193,11 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => {
|
||||
return res.redirect('/admin/herramientas');
|
||||
}
|
||||
try {
|
||||
let foto_url = null;
|
||||
if (req.files && req.files.foto) {
|
||||
foto_url = await guardarFoto(req.files.foto, 'herramienta');
|
||||
}
|
||||
|
||||
await Herramienta.create({
|
||||
company_id: req.session.user.company_id,
|
||||
nombre,
|
||||
@@ -202,7 +208,8 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => {
|
||||
modelo: modelo || null,
|
||||
estado: estado || 'operativo',
|
||||
ubicacion_bodega: ubicacion_bodega || null,
|
||||
notas: notas || null
|
||||
notas: notas || null,
|
||||
foto_url
|
||||
});
|
||||
req.flash('success_msg', `Herramienta "${nombre}" registrada correctamente`);
|
||||
res.redirect('/admin/herramientas#tab-herramientas');
|
||||
@@ -217,7 +224,7 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => {
|
||||
router.post('/herramientas/edit/:id', isCompanyAdmin, async (req, res) => {
|
||||
const { nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas } = req.body;
|
||||
try {
|
||||
await Herramienta.update(req.params.id, req.session.user.company_id, {
|
||||
const updateData = {
|
||||
nombre,
|
||||
codigo,
|
||||
numero_serie,
|
||||
@@ -227,7 +234,13 @@ router.post('/herramientas/edit/:id', isCompanyAdmin, async (req, res) => {
|
||||
estado,
|
||||
ubicacion_bodega,
|
||||
notas
|
||||
});
|
||||
};
|
||||
|
||||
if (req.files && req.files.foto) {
|
||||
updateData.foto_url = await guardarFoto(req.files.foto, 'herramienta');
|
||||
}
|
||||
|
||||
await Herramienta.update(req.params.id, req.session.user.company_id, updateData);
|
||||
req.flash('success_msg', 'Herramienta actualizada exitosamente');
|
||||
res.redirect('/admin/herramientas#tab-herramientas');
|
||||
} catch (error) {
|
||||
@@ -265,12 +278,18 @@ router.post('/herramientas/sets/create', isCompanyAdmin, async (req, res) => {
|
||||
items = [herramientas_ids];
|
||||
}
|
||||
|
||||
let foto_url = null;
|
||||
if (req.files && req.files.foto) {
|
||||
foto_url = await guardarFoto(req.files.foto, 'kit');
|
||||
}
|
||||
|
||||
await HerramientaSet.create({
|
||||
company_id: req.session.user.company_id,
|
||||
nombre,
|
||||
codigo: codigo || null,
|
||||
descripcion: descripcion || null,
|
||||
categoria: categoria || 'general'
|
||||
categoria: categoria || 'general',
|
||||
foto_url
|
||||
}, items);
|
||||
|
||||
req.flash('success_msg', `Kit de herramientas "${nombre}" creado con ${items.length} instrumentos.`);
|
||||
@@ -291,13 +310,19 @@ router.post('/herramientas/sets/edit/:id', isCompanyAdmin, async (req, res) => {
|
||||
items = Array.isArray(herramientas_ids) ? herramientas_ids : (herramientas_ids ? [herramientas_ids] : []);
|
||||
}
|
||||
|
||||
await HerramientaSet.update(req.params.id, req.session.user.company_id, {
|
||||
const updateData = {
|
||||
nombre,
|
||||
codigo,
|
||||
descripcion,
|
||||
categoria,
|
||||
is_active: is_active === '1' || is_active === 1 ? 1 : 0
|
||||
}, items);
|
||||
};
|
||||
|
||||
if (req.files && req.files.foto) {
|
||||
updateData.foto_url = await guardarFoto(req.files.foto, 'kit');
|
||||
}
|
||||
|
||||
await HerramientaSet.update(req.params.id, req.session.user.company_id, updateData, items);
|
||||
|
||||
req.flash('success_msg', 'Set / Kit actualizado exitosamente');
|
||||
res.redirect('/admin/herramientas#tab-sets');
|
||||
@@ -329,6 +354,11 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => {
|
||||
return res.redirect('/admin/herramientas');
|
||||
}
|
||||
try {
|
||||
let foto_entrega_url = null;
|
||||
if (req.files && req.files.foto_entrega) {
|
||||
foto_entrega_url = await guardarFoto(req.files.foto_entrega, 'entrega');
|
||||
}
|
||||
|
||||
await HerramientaAsignacion.create({
|
||||
company_id: req.session.user.company_id,
|
||||
tecnico_id: parseInt(tecnico_id),
|
||||
@@ -336,6 +366,7 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => {
|
||||
herramienta_id: herramienta_id ? parseInt(herramienta_id) : null,
|
||||
tipo_asignacion: tipo_asignacion || 'dotacion_fija',
|
||||
observaciones_entrega: observaciones_entrega || 'Asignación administrativa de equipamiento',
|
||||
foto_entrega_url,
|
||||
asignado_por: req.session.user.id
|
||||
});
|
||||
req.flash('success_msg', 'Equipamiento asignado al técnico exitosamente');
|
||||
@@ -351,9 +382,15 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => {
|
||||
router.post('/herramientas/devolver/:id', isCompanyAdmin, async (req, res) => {
|
||||
const { observaciones_devolucion, estado } = req.body;
|
||||
try {
|
||||
let foto_devolucion_url = null;
|
||||
if (req.files && req.files.foto_devolucion) {
|
||||
foto_devolucion_url = await guardarFoto(req.files.foto_devolucion, 'devolucion');
|
||||
}
|
||||
|
||||
await HerramientaAsignacion.devolver(req.params.id, req.session.user.company_id, {
|
||||
observaciones_devolucion: observaciones_devolucion || 'Devuelto conforme a bodega',
|
||||
estado: estado || 'devuelto'
|
||||
estado: estado || 'devuelto',
|
||||
foto_devolucion_url
|
||||
});
|
||||
req.flash('success_msg', 'Devolución de herramientas registrada correctamente');
|
||||
res.redirect('/admin/herramientas#tab-asignaciones');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const path = require('path');
|
||||
const Herramienta = require('../models/Herramienta');
|
||||
const HerramientaSet = require('../models/HerramientaSet');
|
||||
const HerramientaAsignacion = require('../models/HerramientaAsignacion');
|
||||
const db = require('../config/db');
|
||||
|
||||
async function testFotoFlow() {
|
||||
console.log('=== TEST DE FLUJO DE HERRAMIENTAS Y FOTOGRAFÍAS ===');
|
||||
|
||||
const companyId = 1;
|
||||
|
||||
// 1. Crear herramienta con foto simulada
|
||||
console.log('1. Creando herramienta de prueba con foto_url...');
|
||||
const testToolId = await Herramienta.create({
|
||||
company_id: companyId,
|
||||
nombre: 'Reflectómetro de Prueba OTDR',
|
||||
codigo: 'TEST-FOTO-01',
|
||||
numero_serie: 'SN-TEST-8899',
|
||||
categoria: 'medicion',
|
||||
marca: 'Anritsu',
|
||||
modelo: 'MT9090A',
|
||||
estado: 'operativo',
|
||||
ubicacion_bodega: 'Bodega Central - Estante A1',
|
||||
notas: 'Herramienta para verificar soporte fotográfico',
|
||||
foto_url: '/uploads/herramientas/test_otdr.jpg'
|
||||
});
|
||||
console.log(' ✓ Herramienta creada con ID:', testToolId);
|
||||
|
||||
const fetchedTool = await Herramienta.findById(testToolId, companyId);
|
||||
console.log(' ✓ Herramienta recuperada:', fetchedTool.nombre, '| foto_url:', fetchedTool.foto_url);
|
||||
|
||||
// 2. Crear Set de Herramientas con foto simulada
|
||||
console.log('2. Creando Set con foto_url...');
|
||||
const testSetId = await HerramientaSet.create({
|
||||
company_id: companyId,
|
||||
nombre: 'Kit de Certificación Fotográfico',
|
||||
codigo: 'SET-FOTO-01',
|
||||
descripcion: 'Kit con evidencia fotográfica del maletín completo',
|
||||
categoria: 'fibra_optica',
|
||||
foto_url: '/uploads/herramientas/test_kit.jpg'
|
||||
}, [testToolId]);
|
||||
console.log(' ✓ Set creado con ID:', testSetId);
|
||||
|
||||
const fetchedSet = await HerramientaSet.findById(testSetId, companyId);
|
||||
console.log(' ✓ Set recuperado:', fetchedSet.nombre, '| foto_url:', fetchedSet.foto_url, '| items:', fetchedSet.herramientas.length);
|
||||
|
||||
// 3. Crear Asignación a técnico con foto_entrega_url
|
||||
console.log('3. Creando asignación de custodia con foto_entrega_url...');
|
||||
const [users] = await db.query("SELECT id FROM users WHERE company_id = ? AND rol IN ('agent', 'admin') LIMIT 1", [companyId]);
|
||||
const tecnicoId = users[0].id;
|
||||
|
||||
const asignacionId = await HerramientaAsignacion.create({
|
||||
company_id: companyId,
|
||||
tecnico_id: tecnicoId,
|
||||
set_id: testSetId,
|
||||
tipo_asignacion: 'dotacion_fija',
|
||||
observaciones_entrega: 'Entregado maletín precintado y verificado fotográficamente',
|
||||
foto_entrega_url: '/uploads/herramientas/test_entrega.jpg',
|
||||
asignado_por: tecnicoId
|
||||
});
|
||||
console.log(' ✓ Asignación creada con ID:', asignacionId);
|
||||
|
||||
const fetchedAsig = await HerramientaAsignacion.findById(asignacionId, companyId);
|
||||
console.log(' ✓ Asignación recuperada:', fetchedAsig.set_nombre, '| foto_entrega_url:', fetchedAsig.foto_entrega_url);
|
||||
|
||||
// 4. Registrar Devolución con foto_devolucion_url
|
||||
console.log('4. Registrando devolución con foto_devolucion_url...');
|
||||
await HerramientaAsignacion.devolver(asignacionId, companyId, {
|
||||
observaciones_devolucion: 'Reingresado a bodega con evidencia fotográfica',
|
||||
estado: 'devuelto',
|
||||
foto_devolucion_url: '/uploads/herramientas/test_devolucion.jpg'
|
||||
});
|
||||
|
||||
const returnedAsig = await HerramientaAsignacion.findById(asignacionId, companyId);
|
||||
console.log(' ✓ Devolución verificada:', returnedAsig.estado, '| foto_devolucion_url:', returnedAsig.foto_devolucion_url);
|
||||
|
||||
// 5. Limpieza de datos de prueba
|
||||
console.log('5. Limpiando datos de prueba...');
|
||||
await HerramientaAsignacion.delete(asignacionId, companyId);
|
||||
await HerramientaSet.delete(testSetId, companyId);
|
||||
await Herramienta.delete(testToolId, companyId);
|
||||
console.log(' ✓ Datos de prueba eliminados correctamente.');
|
||||
|
||||
console.log('=== TODOS LOS TESTS DE PERSISTENCIA Y MODELOS PASARON EXITOSAMENTE ===');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
testFotoFlow().catch(err => {
|
||||
console.error('ERROR EN TEST:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+103
-13
@@ -146,6 +146,13 @@
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="app-card h-100 p-3.5 d-flex flex-column justify-content-between border shadow-sm rounded-3">
|
||||
<div>
|
||||
<% if (s.foto_url) { %>
|
||||
<div class="mb-3 rounded overflow-hidden border shadow-sm position-relative" style="height: 140px; background-color: #f8fafc;">
|
||||
<a href="<%= s.foto_url %>" target="_blank" title="Ver foto ampliada">
|
||||
<img src="<%= s.foto_url %>" alt="<%= s.nombre %>" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="badge bg-dark text-white font-monospace" style="font-size: 0.7rem;">
|
||||
<%= s.codigo || 'KIT-' + s.id %>
|
||||
@@ -171,8 +178,12 @@
|
||||
<ul class="list-unstyled mb-0 small" style="font-size: 0.78rem;">
|
||||
<% s.herramientas.forEach(h => { %>
|
||||
<li class="d-flex align-items-center justify-content-between py-1 border-bottom border-light">
|
||||
<div class="text-truncate me-2">
|
||||
<i class="fas fa-check text-success me-1.5" style="font-size: 0.7rem;"></i>
|
||||
<div class="text-truncate me-2 d-flex align-items-center gap-1.5">
|
||||
<% if (h.foto_url) { %>
|
||||
<img src="<%= h.foto_url %>" alt="Foto" class="rounded flex-shrink-0" style="width: 22px; height: 22px; object-fit: cover;">
|
||||
<% } else { %>
|
||||
<i class="fas fa-check text-success" style="font-size: 0.7rem;"></i>
|
||||
<% } %>
|
||||
<strong><%= h.nombre %></strong>
|
||||
<% if (h.marca) { %><span class="text-muted">(<%= h.marca %>)</span><% } %>
|
||||
</div>
|
||||
@@ -211,7 +222,7 @@
|
||||
<div class="modal fade" id="modalEditarSet<%= s.id %>" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/sets/edit/<%= s.id %>" method="POST">
|
||||
<form action="/admin/herramientas/sets/edit/<%= s.id %>" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Editar Kit: <%= s.nombre %></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -247,6 +258,16 @@
|
||||
<label class="form-label small fw-bold">Descripción / Alcance</label>
|
||||
<textarea name="descripcion" class="form-control" rows="2"><%= s.descripcion || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-primary me-1"></i> Fotografía del Set / Maletín</label>
|
||||
<% if (s.foto_url) { %>
|
||||
<div class="d-flex align-items-center gap-2 mb-2 p-2 border rounded bg-light">
|
||||
<img src="<%= s.foto_url %>" alt="Foto" class="rounded border" style="width: 50px; height: 50px; object-fit: cover;">
|
||||
<span class="small text-muted">Fotografía actual del kit registrada. Selecciona una nueva imagen para reemplazarla.</span>
|
||||
</div>
|
||||
<% } %>
|
||||
<input type="file" name="foto" accept="image/*" class="form-control">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold mb-1">Seleccionar Herramientas del Inventario</label>
|
||||
<div class="border rounded p-2.5" style="max-height: 200px; overflow-y: auto;">
|
||||
@@ -277,7 +298,7 @@
|
||||
<div class="modal fade" id="modalAsignarDirecto<%= s.id %>" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/asignar" method="POST">
|
||||
<form action="/admin/herramientas/asignar" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="set_id" value="<%= s.id %>">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title fw-bold">Asignar Kit: <%= s.nombre %></h5>
|
||||
@@ -304,6 +325,11 @@
|
||||
<label class="form-label small fw-bold">Observaciones de Entrega</label>
|
||||
<textarea name="observaciones_entrega" class="form-control" rows="2" placeholder="Ej: Entregado completo con estuche y cargadores en buen estado"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-primary me-1"></i> Foto de Evidencia / Entrega (Opcional)</label>
|
||||
<input type="file" name="foto_entrega" accept="image/*" class="form-control">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Respaldo fotográfico del estado del kit entregado en custodia.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Cancelar</button>
|
||||
@@ -347,8 +373,21 @@
|
||||
<tr>
|
||||
<td class="ps-3 font-monospace fw-bold text-dark"><%= h.codigo || 'HERR-' + h.id %></td>
|
||||
<td>
|
||||
<strong class="text-dark"><%= h.nombre %></strong>
|
||||
<% if (h.notas) { %><div class="small text-muted text-truncate" style="max-width: 250px;"><%= h.notas %></div><% } %>
|
||||
<div class="d-flex align-items-center gap-2.5">
|
||||
<% if (h.foto_url) { %>
|
||||
<a href="<%= h.foto_url %>" target="_blank" class="flex-shrink-0" title="Ver foto ampliada">
|
||||
<img src="<%= h.foto_url %>" alt="<%= h.nombre %>" class="rounded border shadow-sm" style="width: 42px; height: 42px; object-fit: cover;">
|
||||
</a>
|
||||
<% } else { %>
|
||||
<div class="icon-circle bg-light text-muted border flex-shrink-0" style="width: 42px; height: 42px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 0.95rem;">
|
||||
<i class="fas fa-tools"></i>
|
||||
</div>
|
||||
<% } %>
|
||||
<div>
|
||||
<strong class="text-dark"><%= h.nombre %></strong>
|
||||
<% if (h.notas) { %><div class="small text-muted text-truncate" style="max-width: 250px;"><%= h.notas %></div><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border"><%= h.categoria %></span>
|
||||
@@ -387,7 +426,7 @@
|
||||
<div class="modal fade" id="modalEditarHerramienta<%= h.id %>" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/edit/<%= h.id %>" method="POST">
|
||||
<form action="/admin/herramientas/edit/<%= h.id %>" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Editar Herramienta: <%= h.nombre %></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -443,6 +482,16 @@
|
||||
<label class="form-label small fw-bold">Notas Adicionales</label>
|
||||
<textarea name="notas" class="form-control" rows="2"><%= h.notas || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-primary me-1"></i> Fotografía del Instrumento / Placa</label>
|
||||
<% if (h.foto_url) { %>
|
||||
<div class="d-flex align-items-center gap-2 mb-2 p-2 border rounded bg-light">
|
||||
<img src="<%= h.foto_url %>" alt="Foto" class="rounded border" style="width: 50px; height: 50px; object-fit: cover;">
|
||||
<span class="small text-muted">Fotografía actual registrada. Selecciona una nueva imagen si deseas actualizarla.</span>
|
||||
</div>
|
||||
<% } %>
|
||||
<input type="file" name="foto" accept="image/*" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -474,6 +523,7 @@
|
||||
<th>Modalidad</th>
|
||||
<th>Fecha Asignación</th>
|
||||
<th>Contexto / Visita</th>
|
||||
<th>Evidencia Fotográfica</th>
|
||||
<th>Estado</th>
|
||||
<th class="text-end pe-3">Acciones</th>
|
||||
</tr>
|
||||
@@ -481,7 +531,7 @@
|
||||
<tbody>
|
||||
<% if (asignaciones.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4 text-muted">No hay asignaciones registradas actualmente.</td>
|
||||
<td colspan="8" class="text-center py-4 text-muted">No hay asignaciones registradas actualmente.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
|
||||
@@ -523,6 +573,23 @@
|
||||
<span class="text-muted small"><%= a.observaciones_entrega || 'Sin observaciones' %></span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<% if (a.foto_entrega_url) { %>
|
||||
<a href="<%= a.foto_entrega_url %>" target="_blank" class="badge bg-primary-subtle text-primary border border-primary-subtle text-decoration-none d-inline-flex align-items-center gap-1" style="font-size: 0.7rem;">
|
||||
<i class="fas fa-camera"></i> <span>Foto Entrega</span>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (a.foto_devolucion_url) { %>
|
||||
<a href="<%= a.foto_devolucion_url %>" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none d-inline-flex align-items-center gap-1" style="font-size: 0.7rem;">
|
||||
<i class="fas fa-camera"></i> <span>Foto Devolución</span>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (!a.foto_entrega_url && !a.foto_devolucion_url) { %>
|
||||
<span class="text-muted small" style="font-size: 0.72rem;">Sin fotos</span>
|
||||
<% } %>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<% if (a.estado === 'asignado') { %>
|
||||
<span class="badge bg-success"><i class="fas fa-user-check me-1"></i> En Custodia</span>
|
||||
@@ -551,7 +618,7 @@
|
||||
<div class="modal fade" id="modalDevolver<%= a.id %>" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/devolver/<%= a.id %>" method="POST">
|
||||
<form action="/admin/herramientas/devolver/<%= a.id %>" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title fw-bold">Registrar Devolución a Bodega</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
@@ -571,6 +638,11 @@
|
||||
<label class="form-label small fw-bold">Observaciones de Devolución</label>
|
||||
<textarea name="observaciones_devolucion" class="form-control" rows="2" placeholder="Ej: Kit revisado e ingresado completo al estante A1 sin novedades"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-success me-1"></i> Foto de Evidencia de Retorno (Opcional)</label>
|
||||
<input type="file" name="foto_devolucion" accept="image/*" class="form-control">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Constancia fotográfica del estado físico al reingreso en bodega.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Cancelar</button>
|
||||
@@ -596,7 +668,7 @@
|
||||
<div class="modal fade" id="modalNuevaHerramienta" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/create" method="POST">
|
||||
<form action="/admin/herramientas/create" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title fw-bold"><i class="fas fa-plus-circle me-1"></i> Registrar Nueva Herramienta / Instrumento</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
@@ -651,6 +723,11 @@
|
||||
<label class="form-label small fw-bold">Observaciones / Especificaciones</label>
|
||||
<textarea name="notas" class="form-control" rows="2" placeholder="Detalles de calibración, accesorios incluidos o precauciones"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-primary me-1"></i> Fotografía del Instrumento / Placa (Opcional)</label>
|
||||
<input type="file" name="foto" accept="image/*" class="form-control">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Sube una foto del equipo o su número de serie para reconocimiento visual en bodega y campo (JPG, PNG o WEBP).</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -666,7 +743,7 @@
|
||||
<div class="modal fade" id="modalNuevoSet" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/sets/create" method="POST">
|
||||
<form action="/admin/herramientas/sets/create" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header bg-warning text-dark">
|
||||
<h5 class="modal-title fw-bold"><i class="fas fa-layer-group me-1"></i> Crear Set / Kit de Herramientas</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
@@ -695,6 +772,11 @@
|
||||
<label class="form-label small fw-bold">Descripción del Kit</label>
|
||||
<input type="text" name="descripcion" class="form-control" placeholder="Ej: Kit completo para instalación y certificación en campo">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-warning me-1"></i> Fotografía del Set / Maletín (Opcional)</label>
|
||||
<input type="file" name="foto" accept="image/*" class="form-control">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Foto general del kit o maletín con sus componentes organizados.</small>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold mb-1">Seleccionar Herramientas a Integrar en este Set</label>
|
||||
<div class="border rounded p-2.5 bg-light" style="max-height: 220px; overflow-y: auto;">
|
||||
@@ -705,7 +787,10 @@
|
||||
<div class="form-check py-1 border-bottom border-light">
|
||||
<input class="form-check-input" type="checkbox" name="herramientas_ids" value="<%= h.id %>" id="checkNewSet_<%= h.id %>">
|
||||
<label class="form-check-label small d-flex align-items-center justify-content-between" for="checkNewSet_<%= h.id %>">
|
||||
<span>
|
||||
<span class="d-flex align-items-center gap-1.5">
|
||||
<% if (h.foto_url) { %>
|
||||
<img src="<%= h.foto_url %>" alt="Foto" class="rounded" style="width: 22px; height: 22px; object-fit: cover;">
|
||||
<% } %>
|
||||
<strong><%= h.nombre %></strong>
|
||||
<% if (h.marca) { %><span class="text-muted">(<%= h.marca %>)</span><% } %>
|
||||
</span>
|
||||
@@ -733,7 +818,7 @@
|
||||
<div class="modal fade" id="modalAsignar" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content rounded-3 border-0 shadow">
|
||||
<form action="/admin/herramientas/asignar" method="POST">
|
||||
<form action="/admin/herramientas/asignar" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title fw-bold"><i class="fas fa-user-tag me-1"></i> Asignar Set de Herramientas a Técnico</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
@@ -768,6 +853,11 @@
|
||||
<label class="form-label small fw-bold">Observaciones de Entrega</label>
|
||||
<textarea name="observaciones_entrega" class="form-control" rows="2" placeholder="Ej: Entregado maletín completo con precintos y cargadores"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold"><i class="fas fa-camera text-primary me-1"></i> Foto de Evidencia / Entrega (Opcional)</label>
|
||||
<input type="file" name="foto_entrega" accept="image/*" class="form-control">
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Respaldo fotográfico del estado del kit entregado al técnico.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Cancelar</button>
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="app-card h-100 p-3.5 border shadow-sm rounded-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<% if (a.set_foto_url || a.herramienta_foto_url) { %>
|
||||
<div class="mb-3 rounded overflow-hidden border shadow-sm" style="height: 130px; background-color: #f8fafc;">
|
||||
<a href="<%= a.set_foto_url || a.herramienta_foto_url %>" target="_blank" title="Ver foto">
|
||||
<img src="<%= a.set_foto_url || a.herramienta_foto_url %>" alt="Foto Equipamiento" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="badge <%= a.tipo_asignacion === 'dotacion_fija' ? 'bg-info-subtle text-info border border-info-subtle' : 'bg-warning-subtle text-dark border border-warning-subtle' %>" style="font-size: 0.7rem;">
|
||||
<i class="fas <%= a.tipo_asignacion === 'dotacion_fija' ? 'fa-id-badge' : 'fa-truck-pickup' %> me-1"></i>
|
||||
@@ -99,6 +106,15 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (a.foto_entrega_url) { %>
|
||||
<div class="mb-2">
|
||||
<a href="<%= a.foto_entrega_url %>" target="_blank" class="badge bg-primary-subtle text-primary border border-primary-subtle text-decoration-none d-inline-flex align-items-center gap-1.5 py-1 px-2" style="font-size: 0.72rem;">
|
||||
<i class="fas fa-camera"></i>
|
||||
<span>Ver foto de entrega en custodia</span>
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (a.observaciones_entrega) { %>
|
||||
<div class="alert alert-light border p-2 small mb-0 text-muted" style="font-size: 0.75rem;">
|
||||
<i class="fas fa-info-circle me-1 text-info"></i> <%= a.observaciones_entrega %>
|
||||
@@ -136,6 +152,7 @@
|
||||
<th>Fecha Entrega</th>
|
||||
<th>Fecha Devolución</th>
|
||||
<th>Observaciones</th>
|
||||
<th>Evidencias</th>
|
||||
<th class="pe-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -153,6 +170,20 @@
|
||||
<td><%= new Date(h.fecha_asignacion).toLocaleDateString('es-EC') %></td>
|
||||
<td><%= h.fecha_devolucion ? new Date(h.fecha_devolucion).toLocaleDateString('es-EC') : '-' %></td>
|
||||
<td class="text-muted"><%= h.observaciones_devolucion || h.observaciones_entrega || '-' %></td>
|
||||
<td>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<% if (h.foto_entrega_url) { %>
|
||||
<a href="<%= h.foto_entrega_url %>" target="_blank" class="badge bg-primary-subtle text-primary border border-primary-subtle text-decoration-none" style="font-size: 0.68rem;">
|
||||
<i class="fas fa-camera"></i> Entrega
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (h.foto_devolucion_url) { %>
|
||||
<a href="<%= h.foto_devolucion_url %>" target="_blank" class="badge bg-success-subtle text-success border border-success-subtle text-decoration-none" style="font-size: 0.68rem;">
|
||||
<i class="fas fa-camera"></i> Devolución
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="pe-3">
|
||||
<span class="badge <%= h.estado === 'devuelto' ? 'bg-secondary' : 'bg-warning text-dark' %>">
|
||||
<%= h.estado %>
|
||||
|
||||
Reference in New Issue
Block a user