db = Database::getInstance(); $this->log = new ActividadAdmin(); } // ───────────────────────────────────────────────────────────────────────── // PLANTILLAS (admin) // ───────────────────────────────────────────────────────────────────────── public function listar(bool $soloActivos = true): array { $w = $soloActivos ? 'WHERE f.is_active = 1' : ''; return $this->db->fetchAll(" SELECT f.*, u.full_name AS creado_por_nombre, (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) + (SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id) AS total_envios, (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) + (SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id AND tc.estado = 'firmado') AS total_completados FROM lab_formularios f LEFT JOIN admin_users u ON u.id = f.creado_por $w ORDER BY f.created_at DESC "); } public function obtener(int $id): ?array { $row = $this->db->fetch( "SELECT f.*, u.full_name AS creado_por_nombre FROM lab_formularios f LEFT JOIN admin_users u ON u.id = f.creado_por WHERE f.id = ?", [$id] ); if ($row) { $row['esquema_decoded'] = json_decode($row['esquema'], true) ?? []; } return $row ?: null; } public function crear(array $datos, int $adminId): int { $this->validarEsquema($datos['esquema'] ?? '[]'); $id = $this->db->insert('lab_formularios', [ 'nombre' => trim($datos['nombre']), 'descripcion' => $datos['descripcion'] ?? null, 'categoria' => $datos['categoria'] ?? 'otro', 'esquema' => $datos['esquema'] ?? '[]', 'permite_firma' => !empty($datos['permite_firma']) ? 1 : 0, 'requiere_firma' => !empty($datos['requiere_firma']) ? 1 : 0, 'firma_modos' => $datos['firma_modos'] ?? 'canvas,foto', 'doc_encabezado' => $datos['doc_encabezado'] ?? null, 'doc_subtitulo' => $datos['doc_subtitulo'] ?? null, 'doc_logo_base64' => $datos['doc_logo_base64'] ?? null, 'doc_color' => $datos['doc_color'] ?? null, 'doc_pie_pagina' => $datos['doc_pie_pagina'] ?? null, 'creado_por' => $adminId, ]); $this->log->registrar($adminId, 'formularios', 'crear', $id, ['nombre' => $datos['nombre']]); return $id; } public function actualizar(int $id, array $datos, int $adminId): bool { $permitidos = [ 'nombre','descripcion','categoria','esquema', 'permite_firma','requiere_firma','firma_modos','is_active', 'doc_encabezado','doc_subtitulo','doc_logo_base64','doc_color','doc_pie_pagina', ]; $campos = array_intersect_key($datos, array_flip($permitidos)); if (isset($campos['esquema'])) { $this->validarEsquema($campos['esquema']); $row = $this->db->fetch('SELECT version FROM lab_formularios WHERE id = ?', [$id]); $campos['version'] = (int)($row['version'] ?? 0) + 1; } $ok = $this->db->update('lab_formularios', $campos, 'id = ?', [$id]); $this->log->registrar($adminId, 'formularios', 'editar', $id, $campos); // rowCount() = 0 cuando los datos son idénticos a los guardados (no es un error) return true; } // ───────────────────────────────────────────────────────────────────────── // ENVÍOS // ───────────────────────────────────────────────────────────────────────── /** * Crear un envío con datos pre-llenados, genera token único. */ public function crearEnvio(array $datos, int $enviadoPor): int { $token = bin2hex(random_bytes(32)); $expira = null; // El enlace no expira $id = $this->db->insert('lab_form_envios', [ 'formulario_id' => (int)$datos['formulario_id'], 'paciente_id' => !empty($datos['paciente_id']) ? (int)$datos['paciente_id'] : null, 'domicilio_id' => !empty($datos['domicilio_id']) ? (int)$datos['domicilio_id'] : null, 'token' => $token, 'datos_prefilled' => $datos['datos_prefilled'] ?? '{}', 'estado' => 'pendiente', 'enviado_por' => $enviadoPor, 'enviado_via' => $datos['enviado_via'] ?? 'whatsapp', 'expira_en' => $expira, ]); $this->log->registrar($enviadoPor, 'formularios', 'enviar', $id, [ 'formulario_id' => $datos['formulario_id'], 'via' => $datos['enviado_via'] ?? 'whatsapp', ]); return $id; } /** * Obtener envío por token (uso público del cliente). */ public function obtenerPorToken(string $token): ?array { $envio = $this->db->fetch( "SELECT e.*, f.nombre AS form_nombre, f.esquema, f.permite_firma, f.requiere_firma, f.descripcion AS form_descripcion, f.doc_color, f.doc_logo_base64, f.doc_encabezado, f.doc_subtitulo, f.doc_pie_pagina, f.firma_modos, p.nombre_completo AS paciente_nombre, p.numero_documento, p.telefono AS paciente_telefono FROM lab_form_envios e JOIN lab_formularios f ON f.id = e.formulario_id LEFT JOIN lab_pacientes p ON p.id = e.paciente_id WHERE e.token = ?", [$token] ); if (!$envio) return null; if ($envio['estado'] === 'expirado') return null; if ($envio['expira_en'] && strtotime($envio['expira_en']) < time()) { $this->db->update('lab_form_envios', ['estado' => 'expirado'], 'id = ?', [$envio['id']]); return null; } $envio['esquema_decoded'] = json_decode($envio['esquema'], true) ?? []; $envio['prefilled_decoded'] = json_decode($envio['datos_prefilled'], true) ?? []; return $envio; } /** * Guardar respuesta del cliente (firma + datos). */ public function guardarRespuesta(string $token, array $datosCliente, ?string $firmaSvg, string $ip, string $ua): bool { $envio = $this->db->fetch('SELECT id, estado FROM lab_form_envios WHERE token = ?', [$token]); if (!$envio || $envio['estado'] === 'expirado') return false; $tieneCanvas = !empty($firmaSvg); $tieneFoto = !empty($datosCliente['__firma_foto']); $estado = ($tieneCanvas || $tieneFoto) ? 'firmado' : 'completado'; // Hash de verificación SHA-256 (vincula contenido + firma + timestamp) $payloadHash = json_encode($datosCliente, JSON_UNESCAPED_UNICODE) . ($firmaSvg ?? '') . $envio['id'] . $envio['token'] . date('Y-m-d H:i:s'); $hash = hash('sha256', $payloadHash); $this->db->update('lab_form_envios', [ 'datos_cliente' => json_encode($datosCliente, JSON_UNESCAPED_UNICODE), 'firma_svg' => $firmaSvg, 'hash_verificacion' => $hash, 'ip_cliente' => $ip, 'user_agent' => substr($ua, 0, 512), 'estado' => $estado, 'completado_en' => date('Y-m-d H:i:s'), ], 'token = ?', [$token]); return true; } /** * Listar envíos (admin/enfermero). */ public function listarEnvios(array $filtros = [], int $pagina = 1, int $porPagina = 20): array { $origen = $filtros['origen'] ?? ''; // 'turnero' | 'otros' | '' (todos) $offset = ($pagina - 1) * $porPagina; // ── Rama 1: envíos normales (domicilios, envío manual, etc.) ────────── $whereA = ['1=1']; $paramsA = []; if (!empty($filtros['formulario_id'])) { $whereA[] = 'e.formulario_id = ?'; $paramsA[] = $filtros['formulario_id']; } if (!empty($filtros['paciente_id'])) { $whereA[] = 'e.paciente_id = ?'; $paramsA[] = $filtros['paciente_id']; } if (!empty($filtros['enviado_por'])) { $whereA[] = 'e.enviado_por = ?'; $paramsA[] = $filtros['enviado_por']; } if (!empty($filtros['estado'])) { $whereA[] = 'e.estado = ?'; $paramsA[] = $filtros['estado']; } if (!empty($filtros['fecha_desde'])) { $whereA[] = 'DATE(e.created_at) >= ?'; $paramsA[] = $filtros['fecha_desde']; } if (!empty($filtros['fecha_hasta'])) { $whereA[] = 'DATE(e.created_at) <= ?'; $paramsA[] = $filtros['fecha_hasta']; } if (!empty($filtros['paciente'])) { $whereA[] = 'p.nombre_completo LIKE ?'; $paramsA[] = '%' . $filtros['paciente'] . '%'; } $wA = implode(' AND ', $whereA); $sqlA = "SELECT e.id, e.formulario_id, e.estado, e.created_at AS fecha, f.nombre AS form_nombre, f.categoria, p.nombre_completo AS paciente_nombre, u.full_name AS enviado_por_nombre, 'otros' AS origen, e.token, NULL AS turno_codigo FROM lab_form_envios e JOIN lab_formularios f ON f.id = e.formulario_id LEFT JOIN lab_pacientes p ON p.id = e.paciente_id LEFT JOIN admin_users u ON u.id = e.enviado_por WHERE $wA"; // ── Rama 2: consentimientos del turnero ─────────────────────────────── $whereB = ['1=1']; $paramsB = []; if (!empty($filtros['formulario_id'])) { $whereB[] = 'tc.formulario_id = ?'; $paramsB[] = $filtros['formulario_id']; } if (!empty($filtros['estado'])) { // Mapear estados: firmado=firmado, pendiente=pendiente/enviado/visto if ($filtros['estado'] === 'firmado') { $whereB[] = "tc.estado = 'firmado'"; } elseif ($filtros['estado'] === 'pendiente') { $whereB[] = "tc.estado IN ('pendiente','enviado','visto')"; } else { $whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero } } if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) >= ?'; $paramsB[] = $filtros['fecha_desde']; } if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) <= ?'; $paramsB[] = $filtros['fecha_hasta']; } if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; } if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por $wB = implode(' AND ', $whereB); $sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at) AS fecha, f.nombre AS form_nombre, f.categoria, p.nombre_completo AS paciente_nombre, 'Turnero' AS enviado_por_nombre, 'turnero' AS origen, tc.token, t.codigo AS turno_codigo FROM turnero_consentimientos tc JOIN lab_formularios f ON f.id = tc.formulario_id JOIN turnero_turnos t ON t.id = tc.turno_id LEFT JOIN turnero_solicitudes ts ON ts.turno_id = tc.turno_id LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id WHERE $wB"; // ── Combinar según filtro origen ────────────────────────────────────── if ($origen === 'turnero') { $sql = $sqlB; $params = $paramsB; } elseif ($origen === 'otros') { $sql = $sqlA; $params = $paramsA; } else { $sql = "($sqlA) UNION ALL ($sqlB)"; $params = array_merge($paramsA, $paramsB); } $total = $this->db->fetch( "SELECT COUNT(*) AS n FROM ($sql) AS _u", $params )['n'] ?? 0; $rows = $this->db->fetchAll( "SELECT * FROM ($sql) AS _u ORDER BY fecha DESC LIMIT ? OFFSET ?", array_merge($params, [$porPagina, $offset]) ); return [ 'data' => $rows, 'total' => (int)$total, 'pagina' => $pagina, 'por_pagina' => $porPagina, 'paginas' => (int)ceil($total / max(1, $porPagina)), ]; } // ───────────────────────────────────────────────────────────────────────── // Helper // ───────────────────────────────────────────────────────────────────────── private function validarEsquema(string $json): void { $decoded = json_decode($json, true); if (!is_array($decoded)) { throw new InvalidArgumentException('El esquema del formulario no es un JSON válido'); } } }