diff --git a/.agente/ideas.md b/.agente/ideas.md index 2b0be4a..6cc2f56 100644 --- a/.agente/ideas.md +++ b/.agente/ideas.md @@ -1,5 +1,17 @@ # Ideas y Propuestas — Help Desk +## Ideas Implementadas + +1. **Gestión y Asignación de Sets de Herramientas a Técnicos (FSM):** + - **Estado:** ✅ Implementado y desplegado. + - **Modelos:** `Herramienta`, `HerramientaSet`, `HerramientaAsignacion`. + - **Alcance:** + - Catálogo detallado de herramientas con código interno, número de serie, marca, modelo, estado operativo y ubicación en bodega. + - Agrupación en Sets / Kits especializados (ej. Kit Fibra Óptica, Kit CCTV Seguridad, Kit Redes). + - Asignación mixta: Dotación fija (custodia permanente del técnico) o asignación por Visita presencial de campo (FSM) vinculada a la orden de trabajo. + - Interfaz administrativa (`/admin/herramientas`) con pestañas y modales de gestión, y vista del técnico (`/agent/herramientas`) para ver su equipamiento en custodia. + - Endpoint M2M `GET /api/external/herramientas/sets` para consulta de kits por servicios externos. + ## Ideas Pendientes de Evaluación 1. **Canalización M2M de Tickets a `app_ideas` (Canvas Estratégico):** diff --git a/config/setup.js b/config/setup.js index 472a9d8..8edef40 100644 --- a/config/setup.js +++ b/config/setup.js @@ -155,6 +155,8 @@ async function setup() { estado ENUM('programada', 'en_camino', 'en_sitio', 'completada', 'cancelada') DEFAULT 'programada', checklist_json TEXT NULL, historial_json LONGTEXT NULL, + set_herramientas_id INT NULL, + set_herramientas_nombre VARCHAR(150) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (ticket_id), @@ -165,6 +167,152 @@ async function setup() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + await migrateColumn('ticket_visitas', 'set_herramientas_id', 'set_herramientas_id INT NULL'); + await migrateColumn('ticket_visitas', 'set_herramientas_nombre', 'set_herramientas_nombre VARCHAR(150) NULL'); + + // Create herramientas (Catálogo detallado de herramientas físicas e instrumentos) + await conn.query(` + CREATE TABLE IF NOT EXISTS herramientas ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + nombre VARCHAR(150) NOT NULL, + codigo VARCHAR(50) NULL, + numero_serie VARCHAR(100) NULL, + categoria VARCHAR(100) DEFAULT 'general', + marca VARCHAR(100) NULL, + modelo VARCHAR(100) NULL, + estado ENUM('operativo', 'en_mantenimiento', 'danado', 'baja') DEFAULT 'operativo', + ubicacion_bodega VARCHAR(150) NULL, + notas TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (company_id), + INDEX (codigo), + INDEX (estado), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // Create herramientas_sets (Agrupaciones lógicas / Kits de herramientas reutilizables) + await conn.query(` + CREATE TABLE IF NOT EXISTS herramientas_sets ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + nombre VARCHAR(150) NOT NULL, + codigo VARCHAR(50) NULL, + descripcion TEXT NULL, + categoria VARCHAR(100) DEFAULT 'general', + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (company_id), + INDEX (codigo), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // Create herramientas_set_items (Herramientas incluidas en cada Set/Kit) + await conn.query(` + CREATE TABLE IF NOT EXISTS herramientas_set_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + set_id INT NOT NULL, + herramienta_id INT NOT NULL, + cantidad INT DEFAULT 1, + FOREIGN KEY (set_id) REFERENCES herramientas_sets(id) ON DELETE CASCADE, + FOREIGN KEY (herramienta_id) REFERENCES herramientas(id) ON DELETE CASCADE, + UNIQUE KEY idx_set_herramienta (set_id, herramienta_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // Create tecnico_herramientas_asignaciones (Asignación mixta: Dotación fija o por Visita FSM) + await conn.query(` + CREATE TABLE IF NOT EXISTS tecnico_herramientas_asignaciones ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + tecnico_id INT NOT NULL, + set_id INT NULL, + herramienta_id INT NULL, + ticket_visita_id INT NULL, + tipo_asignacion ENUM('dotacion_fija', 'visita_fsm') DEFAULT 'dotacion_fija', + fecha_asignacion DATETIME DEFAULT CURRENT_TIMESTAMP, + fecha_devolucion DATETIME NULL, + estado ENUM('asignado', 'devuelto', 'observado') DEFAULT 'asignado', + observaciones_entrega TEXT NULL, + observaciones_devolucion TEXT NULL, + asignado_por INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (company_id), + INDEX (tecnico_id), + INDEX (ticket_visita_id), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + FOREIGN KEY (tecnico_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (set_id) REFERENCES herramientas_sets(id) ON DELETE SET NULL, + FOREIGN KEY (herramienta_id) REFERENCES herramientas(id) ON DELETE SET NULL, + FOREIGN KEY (ticket_visita_id) REFERENCES ticket_visitas(id) ON DELETE SET NULL, + FOREIGN KEY (asignado_por) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // 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) { + console.log('+ Sembrando catálogo de herramientas para Telcotronics...'); + const initialTools = [ + [1, 'Fusionadora de Fibra Óptica', 'TOOL-FIB-01', 'SN-FIB7021', 'fibra_optica', 'Fujikura', '70S', 'operativo', 'Bodega Central - Estante A1'], + [1, 'OTDR Mini Pro de Precisión', 'TOOL-OTDR-01', 'SN-OTDR4402', 'medicion', 'Anritsu', 'MT9090', 'operativo', 'Bodega Central - Estante A1'], + [1, 'Crimpadora RJ45/RJ11 Pro', 'TOOL-CRIMP-01', 'SN-CRIMP-88', 'redes', 'Klein Tools', 'VDV226', 'operativo', 'Bodega Central - Caja 3'], + [1, 'Tester Certificador Cable de Red PoE', 'TOOL-TEST-01', 'SN-FLK8891', 'medicion', 'Fluke Networks', 'MicroScanner', 'operativo', 'Bodega Central - Estante B2'], + [1, 'Monitor Portátil de Prueba CCTV AHD/IP', 'TOOL-CCTV-01', 'SN-HK9012', 'cctv', 'Hikvision', 'TestScreen Pro 4K', 'operativo', 'Bodega Central - Estante C1'], + [1, 'Taladro Percutor Inalámbrico 20V Max', 'TOOL-TAL-01', 'SN-DW2019', 'mano', 'DeWalt', 'DCD778', 'operativo', 'Bodega Central - Gaveta 5'], + [1, 'Multímetro Digital True RMS', 'TOOL-MULT-01', 'SN-FLK1174', 'electricidad', 'Fluke', '117 Electrician', 'operativo', 'Bodega Central - Estante B2'], + [1, 'Escalera Telescópica de Aluminio 3.8m', 'TOOL-ESC-01', 'SN-ESC-901', 'mano', 'Werner', 'Telescop 3.8', 'operativo', 'Área de Vehículos'] + ]; + for (const t of initialTools) { + await conn.query( + `INSERT INTO herramientas (company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t + ); + } + + // Crear sets + console.log('+ Sembrando Sets / Kits de Herramientas para Telcotronics...'); + const [set1] = await conn.query( + `INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria) + VALUES (1, 'Kit Fibra Óptica & Enlaces', 'SET-FIB-PRO', 'Kit completo para tendido, fusión y certificación de enlaces de fibra óptica.', 'fibra_optica')` + ); + const [set2] = await conn.query( + `INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria) + VALUES (1, 'Kit Seguridad Electrónica & CCTV', 'SET-CCTV-01', 'Herramientas indispensables para montaje, alineación y pruebas de cámaras IP y analógicas.', 'cctv')` + ); + const [set3] = await conn.query( + `INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria) + VALUES (1, 'Kit Redes & Cableado Estructurado', 'SET-RED-01', 'Instrumentos para certificación de puntos de red, crimpado y pruebas PoE.', 'redes')` + ); + + // Asociar herramientas a los sets + const [toolsRows] = await conn.query('SELECT id, codigo FROM herramientas WHERE company_id = 1'); + const toolMap = {}; + toolsRows.forEach(r => { toolMap[r.codigo] = r.id; }); + + // Set 1 items: Fusionadora + OTDR + Crimpadora + if (toolMap['TOOL-FIB-01'] && toolMap['TOOL-OTDR-01']) { + await conn.query('INSERT INTO herramientas_set_items (set_id, herramienta_id, cantidad) VALUES (?, ?, 1), (?, ?, 1)', + [set1.insertId, toolMap['TOOL-FIB-01'], set1.insertId, toolMap['TOOL-OTDR-01']]); + } + // Set 2 items: Monitor CCTV + Taladro + Multímetro + if (toolMap['TOOL-CCTV-01'] && toolMap['TOOL-TAL-01'] && toolMap['TOOL-MULT-01']) { + await conn.query('INSERT INTO herramientas_set_items (set_id, herramienta_id, cantidad) VALUES (?, ?, 1), (?, ?, 1), (?, ?, 1)', + [set2.insertId, toolMap['TOOL-CCTV-01'], set2.insertId, toolMap['TOOL-TAL-01'], set2.insertId, toolMap['TOOL-MULT-01']]); + } + // Set 3 items: Tester Fluke + Crimpadora + Multímetro + if (toolMap['TOOL-TEST-01'] && toolMap['TOOL-CRIMP-01'] && toolMap['TOOL-MULT-01']) { + await conn.query('INSERT INTO herramientas_set_items (set_id, herramienta_id, cantidad) VALUES (?, ?, 1), (?, ?, 1), (?, ?, 1)', + [set3.insertId, toolMap['TOOL-TEST-01'], set3.insertId, toolMap['TOOL-CRIMP-01'], set3.insertId, toolMap['TOOL-MULT-01']]); + } + } + // Seed departments for all ecosystem companies for (const ec of ecosystemCompanies) { const [depts] = await conn.query('SELECT COUNT(*) as c FROM departments WHERE company_id = ?', [ec.id]); diff --git a/models/Herramienta.js b/models/Herramienta.js new file mode 100644 index 0000000..a39de88 --- /dev/null +++ b/models/Herramienta.js @@ -0,0 +1,96 @@ +const db = require('../config/db'); + +class Herramienta { + static async findAll(companyId, filtros = {}) { + let sql = 'SELECT * FROM herramientas WHERE company_id = ?'; + const params = [companyId]; + + if (filtros.estado) { + sql += ' AND estado = ?'; + params.push(filtros.estado); + } + if (filtros.categoria) { + sql += ' AND categoria = ?'; + params.push(filtros.categoria); + } + if (filtros.buscar) { + sql += ' AND (nombre LIKE ? OR codigo LIKE ? OR numero_serie LIKE ? OR marca LIKE ?)'; + const term = `%${filtros.buscar}%`; + params.push(term, term, term, term); + } + + sql += ' ORDER BY id DESC'; + const [rows] = await db.query(sql, params); + return rows; + } + + static async findById(id, companyId) { + const [rows] = await db.query( + 'SELECT * FROM herramientas WHERE id = ? AND company_id = ?', + [id, companyId] + ); + return rows[0] || null; + } + + static async create(data) { + const { + company_id, + nombre, + codigo = null, + numero_serie = null, + categoria = 'general', + marca = null, + modelo = null, + estado = 'operativo', + ubicacion_bodega = null, + notas = 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] + ); + return result.insertId; + } + + static async update(id, companyId, data) { + const fields = []; + const values = []; + + const allowed = ['nombre', 'codigo', 'numero_serie', 'categoria', 'marca', 'modelo', 'estado', 'ubicacion_bodega', 'notas']; + for (const [key, val] of Object.entries(data)) { + if (allowed.includes(key)) { + fields.push(`${key} = ?`); + values.push(val); + } + } + + if (fields.length === 0) return; + values.push(id, companyId); + + await db.query(`UPDATE herramientas SET ${fields.join(', ')} WHERE id = ? AND company_id = ?`, values); + } + + static async delete(id, companyId) { + await db.query('DELETE FROM herramientas WHERE id = ? AND company_id = ?', [id, companyId]); + } + + static async countByEstado(companyId) { + const [rows] = await db.query( + 'SELECT estado, COUNT(*) as count FROM herramientas WHERE company_id = ? GROUP BY estado', + [companyId] + ); + const stats = { operativo: 0, en_mantenimiento: 0, danado: 0, baja: 0, total: 0 }; + rows.forEach(r => { + if (stats[r.estado] !== undefined) { + stats[r.estado] = r.count; + } + stats.total += r.count; + }); + return stats; + } +} + +module.exports = Herramienta; diff --git a/models/HerramientaAsignacion.js b/models/HerramientaAsignacion.js new file mode 100644 index 0000000..8dc6cc0 --- /dev/null +++ b/models/HerramientaAsignacion.js @@ -0,0 +1,104 @@ +const db = require('../config/db'); + +class HerramientaAsignacion { + static async findAll(companyId, filtros = {}) { + let sql = ` + SELECT tha.*, + u.nombre as tecnico_nombre, + u.email as tecnico_email, + hs.nombre as set_nombre, + hs.codigo as set_codigo, + h.nombre as herramienta_nombre, + h.codigo as herramienta_codigo, + tv.titulo as visita_titulo, + tv.ticket_id, + t.asunto as ticket_asunto, + adm.nombre as asignado_por_nombre + 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 + LEFT JOIN ticket_visitas tv ON tha.ticket_visita_id = tv.id + LEFT JOIN tickets t ON tv.ticket_id = t.id + LEFT JOIN users adm ON tha.asignado_por = adm.id + WHERE tha.company_id = ? + `; + const params = [companyId]; + + if (filtros.tecnico_id) { + sql += ' AND tha.tecnico_id = ?'; + params.push(filtros.tecnico_id); + } + if (filtros.estado) { + sql += ' AND tha.estado = ?'; + params.push(filtros.estado); + } + if (filtros.tipo_asignacion) { + sql += ' AND tha.tipo_asignacion = ?'; + params.push(filtros.tipo_asignacion); + } + + sql += ' ORDER BY tha.id DESC'; + const [rows] = await db.query(sql, params); + return rows; + } + + static async findByTecnico(companyId, tecnicoId) { + return await this.findAll(companyId, { tecnico_id: tecnicoId }); + } + + static async findById(id, companyId) { + const [rows] = await db.query( + `SELECT tha.*, + u.nombre as tecnico_nombre, + u.email as tecnico_email, + hs.nombre as set_nombre, + hs.codigo as set_codigo + 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 + WHERE tha.id = ? AND tha.company_id = ?`, + [id, companyId] + ); + return rows[0] || null; + } + + static async create(data) { + const { + company_id, + tecnico_id, + set_id = null, + herramienta_id = null, + ticket_visita_id = null, + tipo_asignacion = 'dotacion_fija', + fecha_asignacion = new Date(), + observaciones_entrega = 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] + ); + 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 delete(id, companyId) { + await db.query('DELETE FROM tecnico_herramientas_asignaciones WHERE id = ? AND company_id = ?', [id, companyId]); + } +} + +module.exports = HerramientaAsignacion; diff --git a/models/HerramientaSet.js b/models/HerramientaSet.js new file mode 100644 index 0000000..c4c61b6 --- /dev/null +++ b/models/HerramientaSet.js @@ -0,0 +1,108 @@ +const db = require('../config/db'); + +class HerramientaSet { + static async findAll(companyId) { + const [sets] = await db.query( + `SELECT hs.*, + COUNT(hsi.id) as total_herramientas, + COALESCE(SUM(hsi.cantidad), 0) as total_items + FROM herramientas_sets hs + LEFT JOIN herramientas_set_items hsi ON hs.id = hsi.set_id + WHERE hs.company_id = ? + GROUP BY hs.id + ORDER BY hs.id DESC`, + [companyId] + ); + + for (const s of sets) { + s.herramientas = await this.getItems(s.id); + } + return sets; + } + + static async findById(id, companyId) { + const [rows] = await db.query( + 'SELECT * FROM herramientas_sets WHERE id = ? AND company_id = ?', + [id, companyId] + ); + if (!rows[0]) return null; + const set = rows[0]; + set.herramientas = await this.getItems(set.id); + return set; + } + + static async getItems(setId) { + const [items] = await db.query( + `SELECT h.*, hsi.cantidad + FROM herramientas_set_items hsi + INNER JOIN herramientas h ON hsi.herramienta_id = h.id + WHERE hsi.set_id = ? + ORDER BY h.nombre ASC`, + [setId] + ); + return items; + } + + static async create(data, itemIds = []) { + const { + company_id, + nombre, + codigo = null, + descripcion = null, + categoria = 'general', + 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] + ); + const setId = res.insertId; + + if (Array.isArray(itemIds) && itemIds.length > 0) { + const values = itemIds.map(hId => [setId, parseInt(hId), 1]); + await db.query( + 'INSERT INTO herramientas_set_items (set_id, herramienta_id, cantidad) VALUES ?', + [values] + ); + } + + return setId; + } + + static async update(id, companyId, data, itemIds = null) { + const fields = []; + const values = []; + + const allowed = ['nombre', 'codigo', 'descripcion', 'categoria', 'is_active']; + for (const [key, val] of Object.entries(data)) { + if (allowed.includes(key)) { + fields.push(`${key} = ?`); + values.push(val); + } + } + + if (fields.length > 0) { + values.push(id, companyId); + await db.query(`UPDATE herramientas_sets SET ${fields.join(', ')} WHERE id = ? AND company_id = ?`, values); + } + + if (Array.isArray(itemIds)) { + await db.query('DELETE FROM herramientas_set_items WHERE set_id = ?', [id]); + if (itemIds.length > 0) { + const valuesItems = itemIds.map(hId => [id, parseInt(hId), 1]); + await db.query( + 'INSERT INTO herramientas_set_items (set_id, herramienta_id, cantidad) VALUES ?', + [valuesItems] + ); + } + } + } + + static async delete(id, companyId) { + await db.query('DELETE FROM herramientas_sets WHERE id = ? AND company_id = ?', [id, companyId]); + } +} + +module.exports = HerramientaSet; diff --git a/models/TicketVisita.js b/models/TicketVisita.js index e8a5e8a..e962732 100644 --- a/models/TicketVisita.js +++ b/models/TicketVisita.js @@ -17,13 +17,15 @@ class TicketVisita { fecha_fin = null, estado = 'programada', checklist_json = null, - historial_json = null + historial_json = null, + set_herramientas_id = null, + set_herramientas_nombre = null } = data; const [result] = await db.query( `INSERT INTO ticket_visitas - (company_id, ticket_id, external_id, tecnico_email, tecnico_nombre, titulo, descripcion, ubicacion, latitud, longitud, fecha_visita, fecha_fin, estado, checklist_json, historial_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (company_id, ticket_id, external_id, tecnico_email, tecnico_nombre, titulo, descripcion, ubicacion, latitud, longitud, fecha_visita, fecha_fin, estado, checklist_json, historial_json, set_herramientas_id, set_herramientas_nombre) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ company_id, ticket_id, @@ -39,7 +41,9 @@ class TicketVisita { fecha_fin, estado, typeof checklist_json === 'object' && checklist_json !== null ? JSON.stringify(checklist_json) : checklist_json, - typeof historial_json === 'object' && historial_json !== null ? JSON.stringify(historial_json) : historial_json + typeof historial_json === 'object' && historial_json !== null ? JSON.stringify(historial_json) : historial_json, + set_herramientas_id, + set_herramientas_nombre ] ); return result.insertId; diff --git a/routes/admin.js b/routes/admin.js index 39fe70c..26bb1cc 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -5,6 +5,9 @@ const { isCompanyAdmin } = require('../middleware/auth'); const User = require('../models/User'); const Department = require('../models/Department'); const Ticket = require('../models/Ticket'); +const Herramienta = require('../models/Herramienta'); +const HerramientaSet = require('../models/HerramientaSet'); +const HerramientaAsignacion = require('../models/HerramientaAsignacion'); router.get('/dashboard', isCompanyAdmin, async (req, res) => { try { @@ -150,4 +153,215 @@ router.post('/departments/delete/:id', isCompanyAdmin, async (req, res) => { } }); +// ========================================== +// GESTIÓN DE HERRAMIENTAS Y SETS / KITS FSM +// ========================================== + +router.get('/herramientas', isCompanyAdmin, async (req, res) => { + try { + const companyId = req.session.user.company_id; + const [herramientas, sets, asignaciones, stats, allUsers] = await Promise.all([ + Herramienta.findAll(companyId), + HerramientaSet.findAll(companyId), + HerramientaAsignacion.findAll(companyId), + Herramienta.countByEstado(companyId), + User.findAll(companyId) + ]); + const tecnicos = allUsers.filter(u => u.rol === 'agent' || u.rol === 'admin'); + + res.render('admin/herramientas', { + title: 'Herramientas & Sets', + herramientas, + sets, + asignaciones, + stats, + tecnicos + }); + } catch (error) { + console.error('Error al cargar herramientas:', error); + req.flash('error_msg', 'Error al cargar gestión de herramientas'); + res.redirect('/admin/dashboard'); + } +}); + +// Crear Herramienta Individual +router.post('/herramientas/create', isCompanyAdmin, async (req, res) => { + const { nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas } = req.body; + if (!nombre) { + req.flash('error_msg', 'El nombre de la herramienta es obligatorio'); + return res.redirect('/admin/herramientas'); + } + try { + await Herramienta.create({ + company_id: req.session.user.company_id, + nombre, + codigo: codigo || null, + numero_serie: numero_serie || null, + categoria: categoria || 'general', + marca: marca || null, + modelo: modelo || null, + estado: estado || 'operativo', + ubicacion_bodega: ubicacion_bodega || null, + notas: notas || null + }); + req.flash('success_msg', `Herramienta "${nombre}" registrada correctamente`); + res.redirect('/admin/herramientas#tab-herramientas'); + } catch (error) { + console.error('Error al crear herramienta:', error); + req.flash('error_msg', 'Error al registrar la herramienta: ' + error.message); + res.redirect('/admin/herramientas'); + } +}); + +// Editar Herramienta Individual +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, { + nombre, + codigo, + numero_serie, + categoria, + marca, + modelo, + estado, + ubicacion_bodega, + notas + }); + req.flash('success_msg', 'Herramienta actualizada exitosamente'); + res.redirect('/admin/herramientas#tab-herramientas'); + } catch (error) { + console.error('Error al actualizar herramienta:', error); + req.flash('error_msg', 'Error al actualizar herramienta: ' + error.message); + res.redirect('/admin/herramientas'); + } +}); + +// Eliminar Herramienta Individual +router.post('/herramientas/delete/:id', isCompanyAdmin, async (req, res) => { + try { + await Herramienta.delete(req.params.id, req.session.user.company_id); + req.flash('success_msg', 'Herramienta eliminada'); + res.redirect('/admin/herramientas#tab-herramientas'); + } catch (error) { + console.error('Error al eliminar herramienta:', error); + req.flash('error_msg', 'Error al eliminar herramienta'); + res.redirect('/admin/herramientas'); + } +}); + +// Crear Set / Kit de Herramientas +router.post('/herramientas/sets/create', isCompanyAdmin, async (req, res) => { + const { nombre, codigo, descripcion, categoria, herramientas_ids } = req.body; + if (!nombre) { + req.flash('error_msg', 'El nombre del Set / Kit es obligatorio'); + return res.redirect('/admin/herramientas'); + } + try { + let items = []; + if (Array.isArray(herramientas_ids)) { + items = herramientas_ids; + } else if (herramientas_ids) { + items = [herramientas_ids]; + } + + await HerramientaSet.create({ + company_id: req.session.user.company_id, + nombre, + codigo: codigo || null, + descripcion: descripcion || null, + categoria: categoria || 'general' + }, items); + + req.flash('success_msg', `Kit de herramientas "${nombre}" creado con ${items.length} instrumentos.`); + res.redirect('/admin/herramientas#tab-sets'); + } catch (error) { + console.error('Error al crear set de herramientas:', error); + req.flash('error_msg', 'Error al crear set: ' + error.message); + res.redirect('/admin/herramientas'); + } +}); + +// Editar Set / Kit de Herramientas +router.post('/herramientas/sets/edit/:id', isCompanyAdmin, async (req, res) => { + const { nombre, codigo, descripcion, categoria, is_active, herramientas_ids } = req.body; + try { + let items = null; + if (herramientas_ids !== undefined) { + items = Array.isArray(herramientas_ids) ? herramientas_ids : (herramientas_ids ? [herramientas_ids] : []); + } + + await HerramientaSet.update(req.params.id, req.session.user.company_id, { + nombre, + codigo, + descripcion, + categoria, + is_active: is_active === '1' || is_active === 1 ? 1 : 0 + }, items); + + req.flash('success_msg', 'Set / Kit actualizado exitosamente'); + res.redirect('/admin/herramientas#tab-sets'); + } catch (error) { + console.error('Error al actualizar set:', error); + req.flash('error_msg', 'Error al actualizar set: ' + error.message); + res.redirect('/admin/herramientas'); + } +}); + +// Eliminar Set / Kit de Herramientas +router.post('/herramientas/sets/delete/:id', isCompanyAdmin, async (req, res) => { + try { + await HerramientaSet.delete(req.params.id, req.session.user.company_id); + req.flash('success_msg', 'Set / Kit eliminado'); + res.redirect('/admin/herramientas#tab-sets'); + } catch (error) { + console.error('Error al eliminar set:', error); + req.flash('error_msg', 'Error al eliminar set'); + res.redirect('/admin/herramientas'); + } +}); + +// Asignar Set de Herramientas a Técnico (Dotación Fija) +router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => { + const { tecnico_id, set_id, herramienta_id, tipo_asignacion, observaciones_entrega } = req.body; + if (!tecnico_id || (!set_id && !herramienta_id)) { + req.flash('error_msg', 'Debe seleccionar un técnico y al menos un set o herramienta'); + return res.redirect('/admin/herramientas'); + } + try { + await HerramientaAsignacion.create({ + company_id: req.session.user.company_id, + tecnico_id: parseInt(tecnico_id), + set_id: set_id ? parseInt(set_id) : null, + herramienta_id: herramienta_id ? parseInt(herramienta_id) : null, + tipo_asignacion: tipo_asignacion || 'dotacion_fija', + observaciones_entrega: observaciones_entrega || 'Asignación administrativa de equipamiento', + asignado_por: req.session.user.id + }); + req.flash('success_msg', 'Equipamiento asignado al técnico exitosamente'); + res.redirect('/admin/herramientas#tab-asignaciones'); + } catch (error) { + console.error('Error al asignar herramientas:', error); + req.flash('error_msg', 'Error al asignar equipamiento: ' + error.message); + res.redirect('/admin/herramientas'); + } +}); + +// Registrar Devolución de Herramientas +router.post('/herramientas/devolver/:id', isCompanyAdmin, async (req, res) => { + const { observaciones_devolucion, estado } = req.body; + try { + await HerramientaAsignacion.devolver(req.params.id, req.session.user.company_id, { + observaciones_devolucion: observaciones_devolucion || 'Devuelto conforme a bodega', + estado: estado || 'devuelto' + }); + req.flash('success_msg', 'Devolución de herramientas registrada correctamente'); + res.redirect('/admin/herramientas#tab-asignaciones'); + } catch (error) { + console.error('Error al registrar devolución:', error); + req.flash('error_msg', 'Error al registrar devolución'); + res.redirect('/admin/herramientas'); + } +}); + module.exports = router; diff --git a/routes/agent.js b/routes/agent.js index 50484d7..20ed33c 100644 --- a/routes/agent.js +++ b/routes/agent.js @@ -4,6 +4,9 @@ const { isAgent } = require('../middleware/auth'); const Ticket = require('../models/Ticket'); const Department = require('../models/Department'); const TicketVisita = require('../models/TicketVisita'); +const HerramientaSet = require('../models/HerramientaSet'); +const HerramientaAsignacion = require('../models/HerramientaAsignacion'); +const User = require('../models/User'); const TareasHubService = require('../config/tareasHubService'); router.get('/dashboard', isAgent, async (req, res) => { @@ -55,8 +58,9 @@ router.get('/ticket/:id', isAgent, async (req, res) => { } const tecnicosHub = await TareasHubService.obtenerTecnicos(); + const setsHerramientas = await HerramientaSet.findAll(ticket.company_id); - res.render('agent/view-ticket', { ticket, respuestas, agents, visita, tecnicosHub }); + res.render('agent/view-ticket', { ticket, respuestas, agents, visita, tecnicosHub, setsHerramientas }); } catch (error) { console.error(error); req.flash('error_msg', 'Error al cargar ticket'); @@ -139,7 +143,8 @@ router.post('/ticket/:id/visita', isAgent, async (req, res) => { fechaVisita, fechaFin, prioridad, - checklist_items + checklist_items, + setHerramientasId } = req.body; if (!emailTecnico) { @@ -147,6 +152,12 @@ router.post('/ticket/:id/visita', isAgent, async (req, res) => { return res.redirect(`/agent/ticket/${req.params.id}`); } + // Buscar información del set de herramientas si se seleccionó uno + let selectedSet = null; + if (setHerramientasId) { + selectedSet = await HerramientaSet.findById(setHerramientasId, ticket.company_id); + } + // Procesar checklist let checklistArr = []; if (typeof checklist_items === 'string') { @@ -182,7 +193,7 @@ router.post('/ticket/:id/visita', isAgent, async (req, res) => { }); // 2. Guardar en MariaDB ticket_visitas - await TicketVisita.create({ + const visitaId = await TicketVisita.create({ company_id: ticket.company_id, ticket_id: ticket.id, external_id: hubRes.tareaId, @@ -195,15 +206,39 @@ router.post('/ticket/:id/visita', isAgent, async (req, res) => { fecha_fin: fechaFin ? new Date(fechaFin) : null, estado: 'programada', checklist_json: checklistArr, - historial_json: hubRes.tarea || null + historial_json: hubRes.tarea || null, + set_herramientas_id: selectedSet ? selectedSet.id : null, + set_herramientas_nombre: selectedSet ? selectedSet.nombre : null }); + // 2.1 Vincular asignación de set de herramientas a nivel FSM + if (selectedSet) { + try { + let tecUser = await User.findByEmail(emailTecnico); + if (!tecUser) { + tecUser = req.session.user; + } + await HerramientaAsignacion.create({ + company_id: ticket.company_id, + tecnico_id: tecUser.id, + set_id: selectedSet.id, + ticket_visita_id: visitaId, + tipo_asignacion: 'visita_fsm', + observaciones_entrega: `Despachado con ${selectedSet.nombre} para visita FSM (Ticket #${ticket.id})`, + asignado_por: req.session.user.id + }); + } catch (errAsign) { + console.warn('Advertencia registrando asignación FSM de herramientas:', errAsign.message); + } + } + // 3. Actualizar estado del ticket a 'en_progreso' await Ticket.update(ticket.id, { status: 'en_progreso' }); // 4. Registrar respuesta en el hilo del ticket const fechaStr = fechaVisita ? new Date(fechaVisita).toLocaleString('es-EC') : 'A coordinar'; - const msgHilo = `📅 **Visita técnica programada**: Se ha asignado al técnico **${tecnicoNombre || emailTecnico}** para el **${fechaStr}**.\n\n📍 **Ubicación:** ${ubicacion || 'En sitio del cliente'}${referencia ? ` (${referencia})` : ''}\n\n📋 **Checklist operativo inicial:**\n${checklistArr.map(c => `- ${c}`).join('\n')}\n\n*ID de tarea en Hub:* \`${hubRes.tareaId}\``; + const kitInfoStr = selectedSet ? `\n🧰 **Set de Herramientas asignado:** ${selectedSet.nombre} (${selectedSet.codigo || 'Kit FSM'})` : ''; + const msgHilo = `📅 **Visita técnica programada**: Se ha asignado al técnico **${tecnicoNombre || emailTecnico}** para el **${fechaStr}**.${kitInfoStr}\n\n📍 **Ubicación:** ${ubicacion || 'En sitio del cliente'}${referencia ? ` (${referencia})` : ''}\n\n📋 **Checklist operativo inicial:**\n${checklistArr.map(c => `- ${c}`).join('\n')}\n\n*ID de tarea en Hub:* \`${hubRes.tareaId}\``; await Ticket.addResponse(ticket.id, req.session.user.id, msgHilo); req.flash('success_msg', 'Visita técnica programada y sincronizada exitosamente con tareas.telcotronics.com'); @@ -271,4 +306,28 @@ router.post('/ticket/:id/visita/sync', isAgent, async (req, res) => { } }); +// Panel de "Mis Herramientas & Sets Asignados" para el Técnico / Agente +router.get('/herramientas', isAgent, async (req, res) => { + try { + const companyId = req.session.user.company_id; + const tecnicoId = req.session.user.id; + + const asignaciones = await HerramientaAsignacion.findByTecnico(companyId, tecnicoId); + + // Agrupar asignaciones activas e historial + const activas = asignaciones.filter(a => a.estado === 'asignado'); + const historial = asignaciones.filter(a => a.estado !== 'asignado'); + + res.render('agent/herramientas', { + title: 'Mis Herramientas', + activas, + historial + }); + } catch (error) { + console.error('Error al cargar herramientas del técnico:', error); + req.flash('error_msg', 'Error al consultar herramientas asignadas'); + res.redirect('/agent/dashboard'); + } +}); + module.exports = router; diff --git a/routes/api.js b/routes/api.js index 7c82288..3092929 100644 --- a/routes/api.js +++ b/routes/api.js @@ -4,6 +4,7 @@ const Ticket = require('../models/Ticket'); const Company = require('../models/Company'); const Department = require('../models/Department'); const User = require('../models/User'); +const HerramientaSet = require('../models/HerramientaSet'); /** * Middleware para validar llamadas M2M mediante API Key o Token de Agente @@ -351,4 +352,42 @@ async function handleAddNote(req, res) { router.post('/external/tickets/:id/notas', handleAddNote); router.post('/tickets/:id/respuestas', handleAddNote); +/** + * GET /api/external/herramientas/sets + * Consulta los sets y kits de herramientas configurados por empresa + */ +router.get('/external/herramientas/sets', async (req, res) => { + try { + const companyId = parseInt(req.query.empresa_id || req.query.company_id || 1); + const sets = await HerramientaSet.findAll(companyId); + res.json({ + ok: true, + success: true, + company_id: companyId, + sets: sets.map(s => ({ + id: s.id, + nombre: s.nombre, + codigo: s.codigo, + categoria: s.categoria, + descripcion: s.descripcion, + total_herramientas: s.total_herramientas, + herramientas: s.herramientas ? s.herramientas.map(h => ({ + id: h.id, + nombre: h.nombre, + codigo: h.codigo, + numero_serie: h.numero_serie, + marca: h.marca, + modelo: h.modelo, + estado: h.estado + })) : [] + })) + }); + } catch (err) { + res.status(500).json({ ok: false, success: false, error: err.message }); + } +}); +router.get('/herramientas/sets', async (req, res) => { + return res.redirect(307, '/api/external/herramientas/sets'); +}); + module.exports = router; diff --git a/views/admin/herramientas.ejs b/views/admin/herramientas.ejs new file mode 100644 index 0000000..d884d6d --- /dev/null +++ b/views/admin/herramientas.ejs @@ -0,0 +1,795 @@ +<%- include('../partials/header') %> +<% title = 'Herramientas & Sets'; %> + + +<% if (success_msg && success_msg.length > 0) { %> + <% success_msg.forEach(msg => { %> +
Administra el inventario de instrumental, kits especializados y asignaciones fijas o por visita presencial a técnicos.
+Agrupa herramientas físicas en sets reutilizables para despacharlos rápidamente en terreno.
+ +<%= s.descripcion || 'Sin descripción detallada.' %>
+ +| Código | +Herramienta / Instrumento | +Categoría | +Marca & Modelo | +Nº Serie | +Ubicación | +Estado | +Acciones | +
|---|---|---|---|---|---|---|---|
| No hay herramientas registradas en el catálogo. | +|||||||
| <%= h.codigo || 'HERR-' + h.id %> | +
+ <%= h.nombre %>
+ <% if (h.notas) { %> <%= h.notas %> <% } %>
+ |
+ + <%= h.categoria %> + | ++ <%= h.marca || '-' %> <%= h.modelo ? `(${h.modelo})` : '' %> + | +<%= h.numero_serie || 'N/D' %> | ++ <%= h.ubicacion_bodega || 'Bodega Central' %> + | ++ <% if (h.estado === 'operativo') { %> + Operativo + <% } else if (h.estado === 'en_mantenimiento') { %> + Mantenimiento + <% } else if (h.estado === 'danado') { %> + Dañado + <% } else { %> + <%= h.estado %> + <% } %> + | ++ + + | +
| Técnico | +Kit / Herramienta Asignada | +Modalidad | +Fecha Asignación | +Contexto / Visita | +Estado | +Acciones | +
|---|---|---|---|---|---|---|
| No hay asignaciones registradas actualmente. | +||||||
|
+ <%= a.tecnico_nombre %>
+ <%= a.tecnico_email %>
+ |
+
+ <% if (a.set_nombre) { %>
+ <%= a.set_nombre %>
+ <% if (a.set_codigo) { %><%= a.set_codigo %><% } %>
+ <% } else if (a.herramienta_nombre) { %>
+ <%= a.herramienta_nombre %>
+ <% if (a.herramienta_codigo) { %><%= a.herramienta_codigo %><% } %>
+ <% } else { %>
+ Equipamiento general
+ <% } %>
+ |
+ + <% if (a.tipo_asignacion === 'dotacion_fija') { %> + Dotación Fija + <% } else { %> + Visita Terreno + <% } %> + | +
+ <%= new Date(a.fecha_asignacion).toLocaleDateString('es-EC') %>
+ <%= new Date(a.fecha_asignacion).toLocaleTimeString('es-EC') %>
+ |
+
+ <% if (a.ticket_id) { %>
+
+ Ticket #<%= a.ticket_id %>
+
+ <%= a.visita_titulo || a.ticket_asunto || '' %>
+ <% } else { %>
+ <%= a.observaciones_entrega || 'Sin observaciones' %>
+ <% } %>
+ |
+
+ <% if (a.estado === 'asignado') { %>
+ En Custodia
+ <% } else if (a.estado === 'devuelto') { %>
+ Devuelto
+ <% if (a.fecha_devolucion) { %>
+ <%= new Date(a.fecha_devolucion).toLocaleDateString('es-EC') %>
+ <% } %>
+ <% } else { %>
+ <%= a.estado %>
+ <% } %>
+ |
+ + <% if (a.estado === 'asignado') { %> + + <% } else { %> + <%= a.observaciones_devolucion || 'Finalizado' %> + <% } %> + | +
Revisa tu equipamiento de dotación oficial y los kits asignados para visitas técnicas presenciales.
+Tu supervisor o administrador te asignará los kits de dotación fija o los kits específicos al programar visitas de terreno.
+| Kit / Herramienta | +Tipo | +Fecha Entrega | +Fecha Devolución | +Observaciones | +Estado | +
|---|---|---|---|---|---|
| + <%= h.set_nombre || h.herramienta_nombre %> + | ++ + <%= h.tipo_asignacion === 'dotacion_fija' ? 'Dotación Fija' : 'Visita FSM' %> + + | +<%= new Date(h.fecha_asignacion).toLocaleDateString('es-EC') %> | +<%= h.fecha_devolucion ? new Date(h.fecha_devolucion).toLocaleDateString('es-EC') : '-' %> | +<%= h.observaciones_devolucion || h.observaciones_entrega || '-' %> | ++ + <%= h.estado %> + + | +