feat(usuarios): gestión de firma por profesional desde perfil de usuario
Botón firma en tabla de usuarios (verde si ya tiene, gris si no). Modal con canvas para dibujar o subir imagen, vista previa de firma actual y opción de eliminar. APIs get/save_firma_usuario.php para admins. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
bad3d42a92
commit
233144c017
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?user_id=X — Devuelve la firma_svg de un usuario (solo admins).
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$userId = (int)($_GET['user_id'] ?? 0);
|
||||
if ($userId <= 0) jsonError('user_id requerido.');
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
$row = $db->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
|
||||
$row->execute([$userId]);
|
||||
$data = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk(['firma_svg' => $data['firma_svg'] ?? null]);
|
||||
@@ -21,6 +21,7 @@ $users = $db->fetchAll("
|
||||
u.enfermera_id,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
(u.firma_svg IS NOT NULL) AS tiene_firma,
|
||||
r.name AS role_name,
|
||||
r.color AS role_color,
|
||||
r.slug AS role_slug,
|
||||
@@ -33,6 +34,7 @@ $users = $db->fetchAll("
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$u['is_active'] = (bool)$u['is_active'];
|
||||
$u['tiene_firma'] = (bool)$u['tiene_firma'];
|
||||
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
|
||||
$u['role_color'] = $u['role_color'] ?? '#0d6efd';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Admin guarda/borra la firma pre-configurada de cualquier usuario.
|
||||
* Body JSON: { user_id: 5, firma_svg: "data:image/png;base64,..." }
|
||||
* { user_id: 5, _borrar: true }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$userId = (int)($body['user_id'] ?? 0);
|
||||
if ($userId <= 0) jsonError('user_id requerido.');
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
if (!empty($body['_borrar'])) {
|
||||
$db->prepare("UPDATE admin_users SET firma_svg = NULL WHERE id = ?")->execute([$userId]);
|
||||
jsonOk(['mensaje' => 'Firma eliminada.']);
|
||||
}
|
||||
|
||||
$svg = $body['firma_svg'] ?? '';
|
||||
if (strlen($svg) < 100) jsonError('Firma requerida.');
|
||||
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
||||
jsonError('Formato de firma no válido.');
|
||||
}
|
||||
|
||||
$db->prepare("UPDATE admin_users SET firma_svg = ? WHERE id = ?")->execute([$svg, $userId]);
|
||||
jsonOk(['mensaje' => 'Firma guardada correctamente.']);
|
||||
@@ -210,6 +210,56 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — FIRMA DE USUARIO
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalFirmaUsuario" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-signature me-2"></i>Firma de <span id="firma-u-nombre"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="firma-u-id">
|
||||
|
||||
<!-- Firma actual -->
|
||||
<div id="firma-u-actual" class="mb-3 d-none">
|
||||
<label class="form-label small fw-semibold text-muted">Firma guardada</label>
|
||||
<div class="border rounded p-2 text-center bg-light">
|
||||
<img id="firma-u-img" src="" alt="Firma" style="max-height:80px;max-width:100%">
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm mt-2 w-100" onclick="firmaUsuario.borrar()">
|
||||
<i class="fas fa-trash me-1"></i>Eliminar firma guardada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Canvas para dibujar -->
|
||||
<label class="form-label small fw-semibold">Dibujar nueva firma</label>
|
||||
<div style="border:1px solid #dee2e6;border-radius:8px;background:#fff;touch-action:none">
|
||||
<canvas id="firma-u-canvas" width="460" height="140" style="width:100%;height:140px;border-radius:8px;cursor:crosshair"></canvas>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="firmaUsuario.limpiarCanvas()">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<div class="ms-auto">
|
||||
<label class="form-label small fw-semibold mb-0 me-2">O subir imagen:</label>
|
||||
<input type="file" id="firma-u-file" accept="image/*" class="form-control form-control-sm d-inline-block" style="width:auto" onchange="firmaUsuario.cargarImagen(this)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger mt-2 d-none" id="firma-u-error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="firmaUsuario.guardar()">
|
||||
<i class="fas fa-save me-1"></i>Guardar firma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — ROL
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
@@ -460,6 +510,11 @@ const usuarios = {
|
||||
<td class="text-muted small">${lastLogin}</td>
|
||||
<td class="text-end table-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button>
|
||||
<button class="btn btn-sm ${u.tiene_firma ? 'btn-success' : 'btn-outline-secondary'} ms-1"
|
||||
onclick="firmaUsuario.abrir(${u.id}, '${esc(u.full_name ?? u.username)}')"
|
||||
title="${u.tiene_firma ? 'Ver / cambiar firma' : 'Subir firma'}">
|
||||
<i class="fas fa-signature"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -596,6 +651,142 @@ document.getElementById('r_slug').addEventListener('input', function() {
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Gestión de firma por usuario ────────────────────────────
|
||||
const firmaUsuario = (() => {
|
||||
let _canvas, _ctx, _drawing = false;
|
||||
|
||||
function initCanvas() {
|
||||
_canvas = document.getElementById('firma-u-canvas');
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_ctx.strokeStyle = '#1e293b';
|
||||
_ctx.lineWidth = 2.2;
|
||||
_ctx.lineCap = 'round';
|
||||
|
||||
const pos = e => {
|
||||
const r = _canvas.getBoundingClientRect();
|
||||
const t = e.touches?.[0] ?? e;
|
||||
return [(t.clientX - r.left) * (_canvas.width / r.width),
|
||||
(t.clientY - r.top) * (_canvas.height / r.height)];
|
||||
};
|
||||
const start = e => { e.preventDefault(); _drawing = true; _ctx.beginPath(); _ctx.moveTo(...pos(e)); };
|
||||
const move = e => { e.preventDefault(); if (!_drawing) return; _ctx.lineTo(...pos(e)); _ctx.stroke(); };
|
||||
const stop = () => { _drawing = false; };
|
||||
|
||||
_canvas.addEventListener('mousedown', start);
|
||||
_canvas.addEventListener('mousemove', move);
|
||||
_canvas.addEventListener('mouseup', stop);
|
||||
_canvas.addEventListener('mouseleave', stop);
|
||||
_canvas.addEventListener('touchstart', start, { passive: false });
|
||||
_canvas.addEventListener('touchmove', move, { passive: false });
|
||||
_canvas.addEventListener('touchend', stop);
|
||||
}
|
||||
|
||||
return {
|
||||
abrir(userId, nombre) {
|
||||
document.getElementById('firma-u-id').value = userId;
|
||||
document.getElementById('firma-u-nombre').textContent = nombre;
|
||||
document.getElementById('firma-u-error').classList.add('d-none');
|
||||
document.getElementById('firma-u-file').value = '';
|
||||
|
||||
// Mostrar firma actual si existe
|
||||
const u = (typeof allUsers !== 'undefined' ? allUsers : []).find(x => x.id === userId);
|
||||
const actualEl = document.getElementById('firma-u-actual');
|
||||
if (u?.tiene_firma) {
|
||||
// Cargar imagen desde servidor
|
||||
fetch(`api/lab/get_firma_usuario.php?user_id=${userId}`)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.firma_svg) {
|
||||
document.getElementById('firma-u-img').src = j.firma_svg;
|
||||
actualEl.classList.remove('d-none');
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
actualEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
if (!_canvas) initCanvas();
|
||||
this.limpiarCanvas();
|
||||
new bootstrap.Modal('#modalFirmaUsuario').show();
|
||||
},
|
||||
|
||||
limpiarCanvas() {
|
||||
if (_ctx) _ctx.clearRect(0, 0, _canvas.width, _canvas.height);
|
||||
},
|
||||
|
||||
cargarImagen(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!_canvas) initCanvas();
|
||||
this.limpiarCanvas();
|
||||
const scale = Math.min(_canvas.width / img.width, _canvas.height / img.height);
|
||||
const w = img.width * scale, h = img.height * scale;
|
||||
_ctx.drawImage(img, (_canvas.width - w) / 2, (_canvas.height - h) / 2, w, h);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||
const errEl = document.getElementById('firma-u-error');
|
||||
errEl.classList.add('d-none');
|
||||
|
||||
// Verificar que el canvas tenga algo dibujado
|
||||
const blank = document.createElement('canvas');
|
||||
blank.width = _canvas.width; blank.height = _canvas.height;
|
||||
if (_canvas.toDataURL() === blank.toDataURL()) {
|
||||
errEl.textContent = 'Dibuja o sube una firma primero.';
|
||||
errEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const png = _canvas.toDataURL('image/png');
|
||||
try {
|
||||
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, firma_svg: png })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) { errEl.textContent = json.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
bootstrap.Modal.getInstance(document.getElementById('modalFirmaUsuario'))?.hide();
|
||||
// Marcar tiene_firma en memoria local para actualizar el botón
|
||||
if (typeof allUsers !== 'undefined') {
|
||||
const u = allUsers.find(x => x.id === userId);
|
||||
if (u) u.tiene_firma = true;
|
||||
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||
}
|
||||
} catch (e) { errEl.textContent = 'Error de conexión.'; errEl.classList.remove('d-none'); }
|
||||
},
|
||||
|
||||
async borrar() {
|
||||
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||
if (!confirm('¿Eliminar la firma guardada de este usuario?')) return;
|
||||
try {
|
||||
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, _borrar: true })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) return;
|
||||
document.getElementById('firma-u-actual').classList.add('d-none');
|
||||
if (typeof allUsers !== 'undefined') {
|
||||
const u = allUsers.find(x => x.id === userId);
|
||||
if (u) u.tiene_firma = false;
|
||||
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/js/lab-sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user