feat(herramientas): agregar soporte de fotografias y evidencias para herramientas, kits y asignaciones de custodia

This commit is contained in:
2026-11-01 03:11:33 -05:00
parent c07162c375
commit b361e4a9b4
11 changed files with 379 additions and 42 deletions
+2
View File
@@ -2,3 +2,5 @@ node_modules/
.env
servers.conf
*.log
public/uploads/herramientas/*
!public/uploads/herramientas/.gitkeep
+10
View File
@@ -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) {
+59
View File
@@ -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
};
+6 -5
View File
@@ -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} = ?`);
+26 -11
View File
@@ -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
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 = ?
WHERE id = ? AND company_id = ?`,
[estado, observaciones_devolucion, id, companyId]
);
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) {
+5 -4
View File
@@ -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
View File
@@ -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');
+91
View File
@@ -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);
});
+101 -11
View File
@@ -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>
<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>
+31
View File
@@ -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 %>