diff --git a/api/adjuntos.php b/api/adjuntos.php new file mode 100644 index 0000000..afe0920 --- /dev/null +++ b/api/adjuntos.php @@ -0,0 +1,270 @@ + 'pdf', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', +]); +define('TAMANIO_MAXIMO', 15 * 1024 * 1024); // 15 MB + +function pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId) { + $stmt = $pdo->prepare('SELECT 1 FROM pacientes WHERE id = ? AND profesional_id = ?'); + $stmt->execute([$pacienteId, $profesionalActivoId]); + return (bool) $stmt->fetch(); +} + +/** + * Convierte un valor de php.ini como "8M", "2G" o "512K" a bytes. + * Si ya es un número plano, lo devuelve tal cual. + */ +function convertirAByte($valor) { + $valor = trim((string) $valor); + if ($valor === '' || $valor === '-1') return 0; // sin límite configurado + $unidad = strtoupper(substr($valor, -1)); + $numero = (float) $valor; + switch ($unidad) { + case 'G': return (int) ($numero * 1024 * 1024 * 1024); + case 'M': return (int) ($numero * 1024 * 1024); + case 'K': return (int) ($numero * 1024); + default: return (int) $valor; + } +} + +/** + * Da un texto legible para humanos a partir de una cantidad de bytes. + */ +function formatearBytesLegible($bytes) { + if ($bytes <= 0) return 'sin límite definido'; + if ($bytes >= 1024 * 1024 * 1024) return round($bytes / (1024 * 1024 * 1024), 1) . ' GB'; + if ($bytes >= 1024 * 1024) return round($bytes / (1024 * 1024), 1) . ' MB'; + return round($bytes / 1024, 1) . ' KB'; +} + +// ------------------------------------------------------------ +// SUBIR ARCHIVO (POST ?accion=subir) — multipart/form-data +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'subir') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + + // Si el archivo (o el POST completo) superó los límites del + // propio servidor (php.ini: upload_max_filesize / post_max_size), + // PHP vacía $_FILES sin avisar el motivo real. Lo detectamos + // comparando contra esos límites para poder dar un mensaje claro, + // en vez de un error confuso de "no se recibió ningún archivo". + if (empty($_FILES) && empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { + $limitePost = convertirAByte(ini_get('post_max_size')); + http_response_code(413); + echo json_encode([ + 'ok' => false, + 'error' => 'El archivo es demasiado grande para este servidor (el límite actual es ' . formatearBytesLegible($limitePost) . '). Probá con un archivo más chico, o pedile a quien administre el hosting que aumente el límite de subida en PHP.', + ]); + exit; + } + + if (empty($_FILES['archivo'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'No se recibió ningún archivo.']); + exit; + } + $pacienteId = $_POST['paciente_id'] ?? 0; + $sesionId = $_POST['sesion_id'] ?? null; + $descripcion = trim($_POST['descripcion'] ?? ''); + + if (!$pacienteId) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el paciente al que pertenece el archivo.']); + exit; + } + + if (!pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $archivo = $_FILES['archivo']; + if ($archivo['error'] === UPLOAD_ERR_INI_SIZE || $archivo['error'] === UPLOAD_ERR_FORM_SIZE) { + $limiteUpload = convertirAByte(ini_get('upload_max_filesize')); + http_response_code(413); + echo json_encode([ + 'ok' => false, + 'error' => 'El archivo supera el límite de subida configurado en este servidor (' . formatearBytesLegible($limiteUpload) . '). Probá con un archivo más chico.', + ]); + exit; + } + if ($archivo['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Error al subir el archivo. Intentá nuevamente.']); + exit; + } + if ($archivo['size'] > TAMANIO_MAXIMO) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El archivo supera el tamaño máximo permitido (15 MB).']); + exit; + } + + if (!function_exists('finfo_open')) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'error' => 'El servidor no tiene habilitada la extensión PHP "fileinfo", necesaria para verificar el tipo de archivo. Pedile a tu hosting que la active (suele estar en "Seleccionar versión de PHP" → extensiones).', + ]); + exit; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo verificar el tipo de archivo en este servidor. Intentá nuevamente o avisá a soporte.']); + exit; + } + $mimeReal = finfo_file($finfo, $archivo['tmp_name']); + finfo_close($finfo); + + if ($mimeReal === false) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo leer el archivo subido. Probá nuevamente.']); + exit; + } + + if (!isset(TIPOS_PERMITIDOS[$mimeReal])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Tipo de archivo no permitido. Solo se aceptan PDF, JPG, PNG o WEBP.']); + exit; + } + + $extension = TIPOS_PERMITIDOS[$mimeReal]; + $nombreArchivo = bin2hex(random_bytes(16)) . '.' . $extension; + $rutaDestino = CARPETA_ADJUNTOS . $nombreArchivo; + + if (!is_dir(CARPETA_ADJUNTOS)) { + mkdir(CARPETA_ADJUNTOS, 0755, true); + } + + if (!move_uploaded_file($archivo['tmp_name'], $rutaDestino)) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo guardar el archivo en el servidor.']); + exit; + } + + $stmt = $pdo->prepare(' + INSERT INTO archivos_adjuntos (paciente_id, sesion_id, nombre_original, nombre_archivo, tipo_mime, tamanio_bytes, descripcion) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $pacienteId, + $sesionId ?: null, + $archivo['name'], + $nombreArchivo, + $mimeReal, + $archivo['size'], + $descripcion ?: null, + ]); + + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'nombre_archivo' => $nombreArchivo]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR ADJUNTOS DE UN PACIENTE (GET ?accion=listar&paciente_id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'listar') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + $pacienteId = $_GET['paciente_id'] ?? 0; + + if (!pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $stmt = $pdo->prepare('SELECT * FROM archivos_adjuntos WHERE paciente_id = ? ORDER BY subido_en DESC'); + $stmt->execute([$pacienteId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// DESCARGAR / VER ARCHIVO (GET ?accion=ver&id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'ver') { + requiereRolProfesional(); + $id = $_GET['id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT a.* FROM archivos_adjuntos a + INNER JOIN pacientes p ON p.id = a.paciente_id + WHERE a.id = ? AND p.profesional_id = ? + '); + $stmt->execute([$id, $profesionalActivoId]); + $archivo = $stmt->fetch(); + + if (!$archivo) { + http_response_code(404); + echo 'Archivo no encontrado.'; + exit; + } + + $ruta = CARPETA_ADJUNTOS . $archivo['nombre_archivo']; + if (!file_exists($ruta)) { + http_response_code(404); + echo 'El archivo ya no está disponible en el servidor.'; + exit; + } + + header('Content-Type: ' . $archivo['tipo_mime']); + header('Content-Disposition: inline; filename="' . basename($archivo['nombre_original']) . '"'); + header('Content-Length: ' . filesize($ruta)); + readfile($ruta); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR ADJUNTO (POST ?accion=eliminar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + + $stmt = $pdo->prepare(' + SELECT a.* FROM archivos_adjuntos a + INNER JOIN pacientes p ON p.id = a.paciente_id + WHERE a.id = ? AND p.profesional_id = ? + '); + $stmt->execute([$id, $profesionalActivoId]); + $archivo = $stmt->fetch(); + + if (!$archivo) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Archivo no encontrado.']); + exit; + } + + $ruta = CARPETA_ADJUNTOS . $archivo['nombre_archivo']; + if (file_exists($ruta)) { + unlink($ruta); + } + + $stmtBorrar = $pdo->prepare('DELETE FROM archivos_adjuntos WHERE id = ?'); + $stmtBorrar->execute([$id]); + + echo json_encode(['ok' => true]); + exit; +} + +header('Content-Type: application/json; charset=utf-8'); +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/admin.php b/api/admin.php new file mode 100644 index 0000000..0fadaf6 --- /dev/null +++ b/api/admin.php @@ -0,0 +1,243 @@ +prepare($sql); + $stmt->execute([$profesionalActivoId, $profesionalActivoId, $_SESSION['usuario_id']]); + $datos = $stmt->fetchAll(); + + $sqlTotal = " + SELECT COUNT(*) AS total FROM historial_cambios h + WHERE + (h.entidad = 'paciente' AND h.entidad_id IN (SELECT id FROM pacientes WHERE profesional_id = ?)) + OR (h.entidad = 'sesion' AND h.entidad_id IN ( + SELECT s.id FROM sesiones s INNER JOIN pacientes p ON p.id = s.paciente_id WHERE p.profesional_id = ? + )) + OR h.usuario_id = ? + "; + $stmtTotal = $pdo->prepare($sqlTotal); + $stmtTotal->execute([$profesionalActivoId, $profesionalActivoId, $_SESSION['usuario_id']]); + $total = $stmtTotal->fetch()['total']; + + echo json_encode(['ok' => true, 'datos' => $datos, 'total' => (int)$total, 'pagina' => $pagina]); + exit; +} + +// ------------------------------------------------------------ +// DASHBOARD DE ESTADÍSTICAS (GET ?accion=estadisticas) +// Acotado siempre al profesional activo. +// ------------------------------------------------------------ +if ($accion === 'estadisticas') { + requiereRolProfesional(); + $profesionalActivoId = idProfesionalActivo(); + $stmtTotal = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE profesional_id = ?'); + $stmtTotal->execute([$profesionalActivoId]); + $totalPacientes = $stmtTotal->fetch()['t']; + + $stmtObras = $pdo->prepare(' + SELECT COALESCE(o.nombre, "Sin especificar") AS nombre, COUNT(*) AS total + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + WHERE p.profesional_id = ? + GROUP BY o.id + ORDER BY total DESC + LIMIT 8 + '); + $stmtObras->execute([$profesionalActivoId]); + $porObraSocial = $stmtObras->fetchAll(); + + $stmtSesionesMes = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? AND s.fecha_sesion BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtSesionesMes->execute([$profesionalActivoId]); + $sesionesEsteMes = $stmtSesionesMes->fetch()['t']; + + $stmtSesionesMesAnterior = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? + AND s.fecha_sesion BETWEEN DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH)) + "); + $stmtSesionesMesAnterior->execute([$profesionalActivoId]); + $sesionesMesAnterior = $stmtSesionesMesAnterior->fetch()['t']; + + $stmtCitasEstado = $pdo->prepare(" + SELECT estado, COUNT(*) AS total FROM citas + WHERE profesional_id = ? AND fecha BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + GROUP BY estado + "); + $stmtCitasEstado->execute([$profesionalActivoId]); + $citasPorEstadoEsteMes = $stmtCitasEstado->fetchAll(); + + $stmtNuevos = $pdo->prepare(" + SELECT COUNT(*) AS t FROM pacientes + WHERE profesional_id = ? AND creado_en BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtNuevos->execute([$profesionalActivoId]); + $pacientesNuevosEsteMes = $stmtNuevos->fetch()['t']; + + $stmtUltimos6 = $pdo->prepare(" + SELECT DATE_FORMAT(s.fecha_sesion, '%Y-%m') AS mes, COUNT(*) AS total + FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? AND s.fecha_sesion >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) + GROUP BY mes + ORDER BY mes ASC + "); + $stmtUltimos6->execute([$profesionalActivoId]); + $sesionesUltimos6Meses = $stmtUltimos6->fetchAll(); + + echo json_encode([ + 'ok' => true, + 'datos' => [ + 'total_pacientes' => (int) $totalPacientes, + 'pacientes_nuevos_mes' => (int) $pacientesNuevosEsteMes, + 'por_obra_social' => $porObraSocial, + 'sesiones_este_mes' => (int) $sesionesEsteMes, + 'sesiones_mes_anterior' => (int) $sesionesMesAnterior, + 'citas_por_estado_mes' => $citasPorEstadoEsteMes, + 'sesiones_ultimos_6_meses' => $sesionesUltimos6Meses, + ], + ]); + exit; +} + +// ------------------------------------------------------------ +// REPORTES POR SEDE (GET ?accion=reporte_sedes) — solo +// desarrollador. Resumen agregado de cada sede activa: cuántos +// profesionales atienden ahí, total de pacientes, sesiones de +// este mes y citas por estado de este mes. +// ------------------------------------------------------------ +if ($accion === 'reporte_sedes') { + requiereDesarrollador(); + + $stmtSedes = $pdo->query('SELECT id, nombre FROM sedes WHERE activa = 1 ORDER BY nombre ASC'); + $sedes = $stmtSedes->fetchAll(); + + $stmtProfesionales = $pdo->prepare(" + SELECT COUNT(DISTINCT u.id) AS t FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.rol = 'profesional' AND u.activo = 1 + "); + $stmtAdministrativas = $pdo->prepare(" + SELECT COUNT(DISTINCT u.id) AS t FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.rol = 'administrativa' AND u.activo = 1 + "); + $stmtPacientes = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE sede_id = ?'); + $stmtSesionesMes = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.sede_id = ? AND s.fecha_sesion BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtCitasMes = $pdo->prepare(" + SELECT c.estado, COUNT(*) AS total FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE p.sede_id = ? AND c.fecha BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + GROUP BY c.estado + "); + + $resultado = []; + foreach ($sedes as $sede) { + $stmtProfesionales->execute([$sede['id']]); + $stmtAdministrativas->execute([$sede['id']]); + $stmtPacientes->execute([$sede['id']]); + $stmtSesionesMes->execute([$sede['id']]); + $stmtCitasMes->execute([$sede['id']]); + + $resultado[] = [ + 'id' => $sede['id'], + 'nombre' => $sede['nombre'], + 'profesionales' => (int) $stmtProfesionales->fetch()['t'], + 'administrativas' => (int) $stmtAdministrativas->fetch()['t'], + 'pacientes' => (int) $stmtPacientes->fetch()['t'], + 'sesiones_mes' => (int) $stmtSesionesMes->fetch()['t'], + 'citas_por_estado_mes' => $stmtCitasMes->fetchAll(), + ]; + } + + echo json_encode(['ok' => true, 'datos' => $resultado]); + exit; +} + +// ------------------------------------------------------------ +// VERIFICAR VERSIÓN (GET ?accion=verificar_version) — solo +// desarrollador. Compara el contenido real de los archivos +// críticos del servidor contra los hashes de referencia que +// vinieron en la última entrega (version.json), para detectar +// de un vistazo si algún archivo quedó con una versión vieja +// después de una actualización a medio subir. +// ------------------------------------------------------------ +if ($accion === 'verificar_version') { + requiereDesarrollador(); + $rutaVersion = __DIR__ . '/../version.json'; + if (!file_exists($rutaVersion)) { + echo json_encode(['ok' => true, 'sin_version_json' => true]); + exit; + } + + $referencia = json_decode(file_get_contents($rutaVersion), true); + $resultado = []; + $hayDesactualizados = false; + + foreach ($referencia['archivos_criticos'] as $rutaRelativa => $hashEsperado) { + $rutaAbsoluta = __DIR__ . '/../' . $rutaRelativa; + if (!file_exists($rutaAbsoluta)) { + $resultado[] = ['archivo' => $rutaRelativa, 'estado' => 'falta', 'hash_esperado' => $hashEsperado, 'hash_real' => null]; + $hayDesactualizados = true; + continue; + } + $hashReal = md5_file($rutaAbsoluta); + $actualizado = ($hashReal === $hashEsperado); + if (!$actualizado) $hayDesactualizados = true; + $resultado[] = [ + 'archivo' => $rutaRelativa, + 'estado' => $actualizado ? 'actualizado' : 'desactualizado', + 'hash_esperado' => $hashEsperado, + 'hash_real' => $hashReal, + ]; + } + + echo json_encode([ + 'ok' => true, + 'version' => $referencia['version'] ?? '?', + 'fecha' => $referencia['fecha'] ?? null, + 'descripcion' => $referencia['descripcion'] ?? null, + 'hay_desactualizados' => $hayDesactualizados, + 'archivos' => $resultado, + ]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..70c0118 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,998 @@ +prepare(' + SELECT u.* FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.activo = 1 AND u.rol IN ("profesional", "administrativa") + ORDER BY u.rol ASC, u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + return $stmt->fetchAll(); +} + +function pinValido($pin) { + return is_string($pin) && preg_match('/^\d{' . LARGO_PIN . '}$/', $pin); +} + +function aplicarBloqueoFuerzaBruta($claveSesionIntentos, $claveSesionBloqueo) { + if (!isset($_SESSION[$claveSesionIntentos])) $_SESSION[$claveSesionIntentos] = 0; + if (!isset($_SESSION[$claveSesionBloqueo])) $_SESSION[$claveSesionBloqueo] = 0; + + if (time() < $_SESSION[$claveSesionBloqueo]) { + $espera = $_SESSION[$claveSesionBloqueo] - time(); + http_response_code(429); + echo json_encode(['ok' => false, 'error' => "Demasiados intentos. Esperá $espera segundos."]); + exit; + } +} + +function registrarIntentoFallido($claveSesionIntentos, $claveSesionBloqueo) { + $_SESSION[$claveSesionIntentos]++; + if ($_SESSION[$claveSesionIntentos] >= 5) { + $_SESSION[$claveSesionBloqueo] = time() + 30; + $_SESSION[$claveSesionIntentos] = 0; + } +} + +// ------------------------------------------------------------ +// ESTADO INICIAL: en qué etapa de configuración está el sistema. +// ------------------------------------------------------------ +if ($accion === 'estado') { + $hayDesarrollador = $pdo->query('SELECT COUNT(*) AS t FROM desarrollador')->fetch()['t'] > 0; + if (!$hayDesarrollador) { + echo json_encode(['ok' => true, 'etapa' => 'sin_desarrollador', 'largo_pin' => LARGO_PIN]); + exit; + } + + $sedesConUsuarios = $pdo->query(' + SELECT COUNT(*) AS t FROM sedes s + INNER JOIN usuarios_sedes us ON us.sede_id = s.id + INNER JOIN usuarios u ON u.id = us.usuario_id AND u.activo = 1 + WHERE s.activa = 1 + ')->fetch()['t']; + + if ($sedesConUsuarios == 0) { + echo json_encode(['ok' => true, 'etapa' => 'sin_sedes_o_usuarios', 'largo_pin' => LARGO_PIN]); + exit; + } + + echo json_encode(['ok' => true, 'etapa' => 'listo', 'largo_pin' => LARGO_PIN]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR SEDES ACTIVAS (paso 1 del login normal). +// ------------------------------------------------------------ +if ($accion === 'listar_sedes_login') { + $stmt = $pdo->query(' + SELECT s.id, s.nombre FROM sedes s + INNER JOIN usuarios_sedes us ON us.sede_id = s.id + INNER JOIN usuarios u ON u.id = us.usuario_id AND u.activo = 1 + WHERE s.activa = 1 + GROUP BY s.id + ORDER BY s.nombre ASC + '); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR USUARIOS DE UNA SEDE (paso 2 del login normal). +// ------------------------------------------------------------ +if ($accion === 'listar_usuarios_sede_login') { + $sedeId = $input['sede_id'] ?? 0; + $usuarios = obtenerUsuariosActivosPorSede($pdo, $sedeId); + $datos = array_map(function ($u) { + return ['id' => $u['id'], 'nombre_completo' => $u['nombre_completo'], 'rol' => $u['rol']]; + }, $usuarios); + echo json_encode(['ok' => true, 'datos' => $datos]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR PROFESIONALES DE UNA SEDE (para que la administrativa +// elija a quién representa, dentro del paso 3 del login). +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_sede_login') { + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT u.id, u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + ORDER BY u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// VERIFICAR PIN (paso 3 del login normal). +// ------------------------------------------------------------ +if ($accion === 'verificar') { + aplicarBloqueoFuerzaBruta('intentos_fallidos', 'bloqueado_hasta'); + + $sedeId = $input['sede_id'] ?? 0; + $usuarioId = $input['usuario_id'] ?? 0; + $pin = trim($input['pin'] ?? ''); + $profesionalActivoId = $input['profesional_activo_id'] ?? null; + + $stmt = $pdo->prepare('SELECT * FROM usuarios WHERE id = ? AND activo = 1'); + $stmt->execute([$usuarioId]); + $usuario = $stmt->fetch(); + + if (!$usuario || !password_verify($pin . APP_SECRET, $usuario['patron_hash'])) { + registrarIntentoFallido('intentos_fallidos', 'bloqueado_hasta'); + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'PIN incorrecto.']); + exit; + } + + $stmtSede = $pdo->prepare('SELECT 1 FROM usuarios_sedes WHERE usuario_id = ? AND sede_id = ?'); + $stmtSede->execute([$usuarioId, $sedeId]); + if (!$stmtSede->fetch()) { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => 'Este usuario no pertenece a la sede elegida.']); + exit; + } + + // Verificar licencia para profesionales: si venció el período + // contratado, bloqueamos el acceso y marcamos como suspendido. + if ($usuario['rol'] === 'profesional') { + $estadoLicencia = $usuario['estado_licencia'] ?? 'activo'; + + // Si tiene días definidos, chequear si el período venció. + if ($estadoLicencia === 'activo' && !empty($usuario['licencia_dias']) && !empty($usuario['licencia_inicio'])) { + $inicio = new DateTime($usuario['licencia_inicio']); + $vencimiento = clone $inicio; + $vencimiento->modify('+' . (int) $usuario['licencia_dias'] . ' days'); + if (new DateTime() > $vencimiento) { + $estadoLicencia = 'suspendido'; + $pdo->prepare('UPDATE usuarios SET estado_licencia = "suspendido" WHERE id = ?')->execute([$usuarioId]); + } + } + + if ($estadoLicencia !== 'activo') { + $mensajes = [ + 'suspendido' => 'Tu licencia de acceso venció. Comunicarte con el administrador del sistema para renovarla.', + 'pausado' => 'Tu cuenta está pausada temporalmente. Comunicarte con el administrador del sistema.', + 'prohibido' => 'Tu cuenta está inhabilitada. Comunicarte con el administrador del sistema.', + ]; + http_response_code(403); + echo json_encode(['ok' => false, 'error' => $mensajes[$estadoLicencia] ?? 'No podés acceder al sistema en este momento.']); + exit; + } + } + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = $usuario['id']; + $_SESSION['nombre_usuario'] = $usuario['nombre_completo']; + $_SESSION['rol'] = $usuario['rol']; + $_SESSION['sede_id'] = (int) $sedeId; + $_SESSION['intentos_fallidos'] = 0; + + $nombreParaMostrar = $usuario['nombre_completo']; + + if ($usuario['rol'] === 'administrativa') { + if (!$profesionalActivoId) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta indicar a qué profesional representa esta administrativa.']); + exit; + } + $stmtProf = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtProf->execute([$profesionalActivoId, $sedeId]); + $profesionalRepresentado = $stmtProf->fetch(); + if (!$profesionalRepresentado) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El profesional elegido no pertenece a esta sede.']); + exit; + } + $_SESSION['profesional_activo_id'] = (int) $profesionalActivoId; + // En pantalla se muestra el nombre del profesional, nunca el + // de la administrativa, para que no se "mezclen" las cuentas + // visualmente. Internamente (auditoría, etc.) se sigue + // registrando que fue la administrativa quien actuó. + $nombreParaMostrar = $profesionalRepresentado['nombre_completo']; + } + + echo json_encode([ + 'ok' => true, + 'nombre_usuario' => $nombreParaMostrar, + 'rol' => $usuario['rol'], + ]); + exit; +} + +// ============================================================ +// DESARROLLADOR +// ============================================================ + +if ($accion === 'crear_desarrollador') { + $hay = $pdo->query('SELECT COUNT(*) AS t FROM desarrollador')->fetch()['t']; + if ($hay > 0) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe una clave de desarrollador configurada.']); + exit; + } + $clave = trim($input['clave'] ?? ''); + if (!pinValido($clave)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'La clave debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + $hash = password_hash($clave . APP_SECRET, PASSWORD_BCRYPT); + $pdo->prepare('INSERT INTO desarrollador (clave_hash) VALUES (?)')->execute([$hash]); + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = null; + $_SESSION['nombre_usuario'] = 'Desarrollador'; + $_SESSION['rol'] = 'desarrollador'; + + echo json_encode(['ok' => true, 'rol' => 'desarrollador', 'nombre_usuario' => 'Desarrollador']); + exit; +} + +if ($accion === 'verificar_desarrollador') { + aplicarBloqueoFuerzaBruta('dev_intentos_fallidos', 'dev_bloqueado_hasta'); + + $clave = trim($input['clave'] ?? ''); + $fila = $pdo->query('SELECT * FROM desarrollador ORDER BY id ASC LIMIT 1')->fetch(); + + if (!$fila || !password_verify($clave . APP_SECRET, $fila['clave_hash'])) { + registrarIntentoFallido('dev_intentos_fallidos', 'dev_bloqueado_hasta'); + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'Clave incorrecta.']); + exit; + } + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = null; + $_SESSION['nombre_usuario'] = 'Desarrollador'; + $_SESSION['rol'] = 'desarrollador'; + $_SESSION['dev_intentos_fallidos'] = 0; + + echo json_encode(['ok' => true, 'rol' => 'desarrollador', 'nombre_usuario' => 'Desarrollador']); + exit; +} + +// ------------------------------------------------------------ +// GESTIÓN DE SEDES (solo desarrollador) +// ------------------------------------------------------------ +if ($accion === 'crear_sede') { + requiereDesarrollador(); + $nombre = trim($input['nombre'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre de la sede.']); + exit; + } + + // Si ya existe una sede con ese nombre pero está desactivada, + // la reactivamos en vez de fallar por el nombre duplicado. + $stmtExistente = $pdo->prepare('SELECT id, activa FROM sedes WHERE nombre = ?'); + $stmtExistente->execute([$nombre]); + $existente = $stmtExistente->fetch(); + + if ($existente && $existente['activa']) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe una sede activa con ese nombre.']); + exit; + } + + if ($existente && !$existente['activa']) { + $pdo->prepare('UPDATE sedes SET activa = 1 WHERE id = ?')->execute([$existente['id']]); + registrarAuditoria($pdo, 'crear', 'sede', $existente['id'], "Se reactivó la sede \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $existente['id']]); + exit; + } + + $pdo->prepare('INSERT INTO sedes (nombre) VALUES (?)')->execute([$nombre]); + registrarAuditoria($pdo, 'crear', 'sede', $pdo->lastInsertId(), "Se creó la sede \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId()]); + exit; +} + +if ($accion === 'listar_sedes') { + requiereDesarrollador(); + $stmt = $pdo->query('SELECT * FROM sedes WHERE activa = 1 ORDER BY nombre ASC'); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($accion === 'desactivar_sede') { + requiereDesarrollador(); + $id = $input['id'] ?? 0; + $pdo->prepare('UPDATE sedes SET activa = 0 WHERE id = ?')->execute([$id]); + registrarAuditoria($pdo, 'desactivar', 'sede', $id, 'Se desactivó una sede.'); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): listar profesionales de una sede, +// para elegir de quién ver la papelera y a quién reasignar. +// (Reutiliza la misma idea que listar_profesionales_sede_login, +// pero accesible solo para el Desarrollador.) +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_sede_dev') { + requiereDesarrollador(); + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT u.id, u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + ORDER BY u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): ver los legajos eliminados de un +// profesional específico (GET vía POST con accion en el body, +// igual que el resto de este archivo). +// ------------------------------------------------------------ +if ($accion === 'listar_papelera_dev') { + requiereDesarrollador(); + $profesionalId = $input['profesional_id'] ?? 0; + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT id, paciente_id_original, nombre_completo, dni, eliminado_en, sede_id_original + FROM legajos_eliminados + WHERE profesional_id_original = ? AND sede_id_original = ? + ORDER BY eliminado_en DESC + '); + $stmt->execute([$profesionalId, $sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): recuperar un legajo eliminado, +// asignándolo a un profesional de la MISMA sede donde estaba +// originalmente (no se permite recuperar hacia otra sede, para +// evitar que un paciente "viaje" de sede sin que el Desarrollador +// lo haga a propósito desde la ficha del paciente ya recuperado). +// ------------------------------------------------------------ +if ($accion === 'recuperar_legajo_dev') { + requiereDesarrollador(); + $idPapelera = $input['id'] ?? 0; + $nuevoProfesionalId = $input['profesional_id'] ?? 0; + + $stmt = $pdo->prepare('SELECT * FROM legajos_eliminados WHERE id = ?'); + $stmt->execute([$idPapelera]); + $registro = $stmt->fetch(); + if (!$registro) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese registro de la papelera no existe.']); + exit; + } + + $sedeOriginalId = $registro['sede_id_original']; + + // Confirmar que el nuevo profesional realmente atienda en esa misma sede. + $stmtCheck = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtCheck->execute([$nuevoProfesionalId, $sedeOriginalId]); + $nuevoProfesional = $stmtCheck->fetch(); + if (!$nuevoProfesional) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ese profesional no atiende en la sede donde estaba este legajo.']); + exit; + } + + $datos = json_decode($registro['datos_json'], true); + $sesionesGuardadas = $datos['sesiones'] ?? []; + unset($datos['sesiones'], $datos['edad']); // no son columnas de la tabla pacientes + + // Si el legajo se está asignando a un profesional DISTINTO del + // que lo tenía antes de eliminarlo, guardamos el nombre del + // profesional original para que quede visible en la ficha. + $nombreProfesionalAnterior = null; + $profesionalOriginalId = $registro['profesional_id_original']; + if ($profesionalOriginalId && (int) $profesionalOriginalId !== (int) $nuevoProfesionalId) { + $stmtAnterior = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmtAnterior->execute([$profesionalOriginalId]); + $filaAnterior = $stmtAnterior->fetch(); + if ($filaAnterior) { + $nombreProfesionalAnterior = $filaAnterior['nombre_completo']; + } + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare(' + INSERT INTO pacientes + (profesional_id, sede_id, recuperado_de_profesional, nombre, apellido, dni, fecha_nacimiento, sexo, obra_social_id, numero_afiliado, + telefono, email, direccion, motivo_consulta, patologia, sintomas, observaciones_generales) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $nuevoProfesionalId, + $sedeOriginalId, + $nombreProfesionalAnterior, + $datos['nombre'] ?? '', + $datos['apellido'] ?? '', + $datos['dni'] ?? '', + $datos['fecha_nacimiento'] ?? null, + $datos['sexo'] ?? 'Otro', + $datos['obra_social_id'] ?? null, + $datos['numero_afiliado'] ?? null, + $datos['telefono'] ?? null, + $datos['email'] ?? null, + $datos['direccion'] ?? null, + $datos['motivo_consulta'] ?? null, + $datos['patologia'] ?? null, + $datos['sintomas'] ?? null, + $datos['observaciones_generales'] ?? null, + ]); + $nuevoPacienteId = $pdo->lastInsertId(); + + if (!empty($sesionesGuardadas) && is_array($sesionesGuardadas)) { + $stmtSesion = $pdo->prepare('INSERT INTO sesiones (paciente_id, fecha_sesion, descripcion, evolucion, proxima_cita) VALUES (?, ?, ?, ?, ?)'); + foreach ($sesionesGuardadas as $s) { + $stmtSesion->execute([ + $nuevoPacienteId, + $s['fecha_sesion'] ?? date('Y-m-d'), + $s['descripcion'] ?? '', + $s['evolucion'] ?? null, + $s['proxima_cita'] ?? null, + ]); + } + } + + $pdo->prepare('DELETE FROM legajos_eliminados WHERE id = ?')->execute([$idPapelera]); + + $pdo->commit(); + registrarAuditoria( + $pdo, 'crear', 'paciente', $nuevoPacienteId, + "Se recuperó de la papelera el legajo de {$datos['nombre']} {$datos['apellido']}, asignado a {$nuevoProfesional['nombre_completo']}." + ); + echo json_encode(['ok' => true, 'id' => $nuevoPacienteId]); + } catch (Exception $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo recuperar el legajo.']); + } + exit; +} + +// ------------------------------------------------------------ +// PROFESIONALES DESACTIVADOS (DESARROLLADOR): listar usuarios +// con rol profesional que ya no tienen acceso (activo = 0), para +// poder ver y reasignar los legajos que les quedaron a cargo. +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_desactivados') { + requiereDesarrollador(); + $stmt = $pdo->query(" + SELECT u.id, u.nombre_completo, + (SELECT COUNT(*) FROM pacientes p WHERE p.profesional_id = u.id) AS total_pacientes + FROM usuarios u + WHERE u.rol = 'profesional' AND u.activo = 0 + HAVING total_pacientes > 0 + ORDER BY u.nombre_completo ASC + "); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LEGAJOS ACTIVOS DE UN PROFESIONAL DESACTIVADO (DESARROLLADOR) +// ------------------------------------------------------------ +if ($accion === 'listar_legajos_huerfanos') { + requiereDesarrollador(); + $profesionalId = $input['profesional_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT p.id, p.nombre, p.apellido, p.dni, p.sede_id, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN sedes s ON s.id = p.sede_id + WHERE p.profesional_id = ? + ORDER BY p.apellido ASC, p.nombre ASC + '); + $stmt->execute([$profesionalId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// REASIGNAR LEGAJO HUÉRFANO (DESARROLLADOR): transfiere un +// paciente activo a otro profesional de la MISMA sede donde +// está el paciente (misma regla que la papelera: no se permite +// cambiar de sede de paso, solo de profesional). +// ------------------------------------------------------------ +if ($accion === 'reasignar_legajo_huerfano') { + requiereDesarrollador(); + $pacienteId = $input['paciente_id'] ?? 0; + $nuevoProfesionalId = $input['profesional_id'] ?? 0; + + $stmtPaciente = $pdo->prepare('SELECT * FROM pacientes WHERE id = ?'); + $stmtPaciente->execute([$pacienteId]); + $paciente = $stmtPaciente->fetch(); + if (!$paciente) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese paciente no existe.']); + exit; + } + + $stmtCheck = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtCheck->execute([$nuevoProfesionalId, $paciente['sede_id']]); + $nuevoProfesional = $stmtCheck->fetch(); + if (!$nuevoProfesional) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ese profesional no atiende en la sede de este paciente.']); + exit; + } + + // Si el profesional anterior queda registrado, lo guardamos + // como "recuperado_de_profesional" igual que en la papelera, + // para que quede el mismo aviso en la ficha del paciente. + $nombreProfesionalAnterior = null; + if ($paciente['profesional_id'] && (int) $paciente['profesional_id'] !== (int) $nuevoProfesionalId) { + $stmtAnterior = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmtAnterior->execute([$paciente['profesional_id']]); + $filaAnterior = $stmtAnterior->fetch(); + if ($filaAnterior) $nombreProfesionalAnterior = $filaAnterior['nombre_completo']; + } + + try { + $pdo->prepare('UPDATE pacientes SET profesional_id = ?, recuperado_de_profesional = ? WHERE id = ?') + ->execute([$nuevoProfesionalId, $nombreProfesionalAnterior, $pacienteId]); + + registrarAuditoria( + $pdo, 'editar', 'paciente', $pacienteId, + "Se transfirió el legajo de {$paciente['nombre']} {$paciente['apellido']} a {$nuevoProfesional['nombre_completo']} (profesional anterior desactivado)." + ); + echo json_encode(['ok' => true]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'El profesional elegido ya tiene un paciente con ese mismo DNI.']); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo transferir el legajo.']); + } + } + exit; +} + +// ------------------------------------------------------------ +// CONFIGURACIÓN INICIAL: crea la primera sede y el primer +// profesional en un solo paso, justo después de crear la +// clave de Desarrollador. +// ------------------------------------------------------------ +if ($accion === 'crear_setup_inicial') { + requiereDesarrollador(); + $nombreSede = trim($input['nombre_sede'] ?? ''); + $nombreProfesional = trim($input['nombre_profesional'] ?? ''); + $pin = trim($input['pin'] ?? ''); + + if ($nombreSede === '' || $nombreProfesional === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Completá el nombre de la sede y del profesional.']); + exit; + } + if (!pinValido($pin)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + + $pdo->beginTransaction(); + try { + $pdo->prepare('INSERT INTO sedes (nombre) VALUES (?)')->execute([$nombreSede]); + $sedeId = $pdo->lastInsertId(); + + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash) VALUES (?, "profesional", ?)') + ->execute([$nombreProfesional, $hash]); + $usuarioId = $pdo->lastInsertId(); + + $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)')->execute([$usuarioId, $sedeId]); + + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'sede', $sedeId, "Configuración inicial: sede \"$nombreSede\" y profesional \"$nombreProfesional\"."); + echo json_encode(['ok' => true, 'sede_id' => $sedeId, 'usuario_id' => $usuarioId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo completar la configuración inicial.']); + } + exit; +} + +// ------------------------------------------------------------ +// CREAR USUARIO (profesional o administrativa) — solo desarrollador +// ------------------------------------------------------------ +// ------------------------------------------------------------ +// CREAR PROFESIONAL (legajo completo) — solo desarrollador. +// Reemplaza el alta simple anterior: ahora incluye datos +// personales, especialidad, sede(s), PIN y tipo de licencia. +// Para administrativas se mantiene el alta simple de antes. +// ------------------------------------------------------------ +if ($accion === 'crear_usuario') { + requiereDesarrollador(); + $rol = $input['rol'] ?? ''; + $pin = trim($input['pin'] ?? ''); + $sedeIds = $input['sede_ids'] ?? []; + + if (!in_array($rol, ['profesional', 'administrativa'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Rol no válido.']); + exit; + } + if (!pinValido($pin)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + if (empty($sedeIds) || !is_array($sedeIds)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Elegí al menos una sede.']); + exit; + } + + if ($rol === 'profesional') { + $titulo = $input['titulo'] ?? 'Dr.'; + $nombre = trim($input['nombre'] ?? ''); + $apellido = trim($input['apellido'] ?? ''); + $dni = trim($input['dni'] ?? '') ?: null; + $fechaNac = $input['fecha_nacimiento'] ?? null ?: null; + $lugarNac = trim($input['lugar_nacimiento'] ?? '') ?: null; + $especialidad = trim($input['especialidad'] ?? '') ?: null; + $email = trim($input['email'] ?? '') ?: null; + $telefono = trim($input['telefono'] ?? '') ?: null; + $licenciaDias = $input['licencia_dias'] !== '' ? (int) $input['licencia_dias'] : null; + + if ($nombre === '' || $apellido === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre y apellido del profesional.']); + exit; + } + + $titulos = ['Dr.', 'Dra.', 'Lic.', 'Tec.', 'Mg.', 'Prof.', 'Otro']; + if (!in_array($titulo, $titulos)) $titulo = 'Dr.'; + + $nombreCompleto = "$titulo $nombre $apellido"; + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash, estado_licencia, licencia_dias, licencia_inicio) VALUES (?, "profesional", ?, "activo", ?, CURDATE())'); + $stmt->execute([$nombreCompleto, $hash, $licenciaDias]); + $nuevoId = $pdo->lastInsertId(); + + $stmtLegajo = $pdo->prepare('INSERT INTO profesionales_legajos (usuario_id, titulo, nombre, apellido, dni, fecha_nacimiento, lugar_nacimiento, especialidad, email, telefono) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + $stmtLegajo->execute([$nuevoId, $titulo, $nombre, $apellido, $dni, $fechaNac, $lugarNac, $especialidad, $email, $telefono]); + + $stmtSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIds as $sid) $stmtSede->execute([$nuevoId, $sid]); + + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'usuario', $nuevoId, "Se creó el legajo del profesional \"$nombreCompleto\"."); + echo json_encode(['ok' => true, 'id' => $nuevoId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo crear el legajo.']); + } + } else { + // Administrativa: alta simple como antes + $nombre = trim($input['nombre_completo'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre de la administrativa.']); + exit; + } + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash) VALUES (?, "administrativa", ?)'); + $stmt->execute([$nombre, $hash]); + $nuevoId = $pdo->lastInsertId(); + $stmtSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIds as $sid) $stmtSede->execute([$nuevoId, $sid]); + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'usuario', $nuevoId, "Se creó la administrativa \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $nuevoId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo crear la administrativa.']); + } + } + exit; +} + +// ------------------------------------------------------------ +// OBTENER LEGAJO DE UN PROFESIONAL — Desarrollador y el propio +// profesional pueden consultar. +// ------------------------------------------------------------ +if ($accion === 'obtener_legajo_profesional') { + requiereSesion(); + $rol = $_SESSION['rol'] ?? ''; + $usuarioId = (int) ($input['usuario_id'] ?? 0); + + // El profesional solo puede ver su propio legajo. + if ($rol === 'profesional') { + $usuarioId = (int) $_SESSION['usuario_id']; + } elseif ($rol !== 'desarrollador') { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => 'Sin permiso.']); + exit; + } + + $stmt = $pdo->prepare(' + SELECT pl.*, u.estado_licencia, u.licencia_dias, u.licencia_inicio, u.activo, + CASE + WHEN u.licencia_dias IS NULL THEN NULL + ELSE DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) + END AS licencia_vencimiento + FROM profesionales_legajos pl + INNER JOIN usuarios u ON u.id = pl.usuario_id + WHERE pl.usuario_id = ? + '); + $stmt->execute([$usuarioId]); + $legajo = $stmt->fetch(); + if (!$legajo) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Legajo no encontrado.']); + exit; + } + echo json_encode(['ok' => true, 'datos' => $legajo]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR LICENCIA — solo desarrollador. +// Permite activar (con duración), pausar, prohibir el acceso. +// ------------------------------------------------------------ +if ($accion === 'actualizar_licencia') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $nuevoEstado = $input['estado'] ?? ''; + $diasLicencia = isset($input['dias']) && $input['dias'] !== '' ? (int) $input['dias'] : null; + + $estadosValidos = ['activo', 'pausado', 'prohibido']; + if (!in_array($nuevoEstado, $estadosValidos)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Estado no válido.']); + exit; + } + + $licenciaInicio = $nuevoEstado === 'activo' ? date('Y-m-d') : null; + $pdo->prepare('UPDATE usuarios SET estado_licencia = ?, licencia_dias = ?, licencia_inicio = ? WHERE id = ?') + ->execute([$nuevoEstado, $nuevoEstado === 'activo' ? $diasLicencia : null, $licenciaInicio, $usuarioId]); + + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, "Se cambió el estado de licencia a \"$nuevoEstado\"" . ($licenciaInicio && $diasLicencia ? " por $diasLicencia días." : ".")); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR USUARIOS (con sus sedes) — solo desarrollador. +// Solo se muestran los activos: los desactivados quedan +// fuera de este listado (pero siguen existiendo en la base, +// así que el historial de cambios sigue siendo legible). +// ------------------------------------------------------------ +if ($accion === 'listar_usuarios') { + requiereDesarrollador(); + $stmt = $pdo->query(" + SELECT u.id, u.nombre_completo, u.rol, u.activo, u.creado_en, + u.estado_licencia, u.licencia_dias, u.licencia_inicio, + CASE + WHEN u.licencia_dias IS NULL THEN NULL + ELSE DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) + END AS licencia_vencimiento, + pl.titulo, pl.especialidad + FROM usuarios u + LEFT JOIN profesionales_legajos pl ON pl.usuario_id = u.id + WHERE u.activo = 1 + ORDER BY u.creado_en ASC + "); + $usuarios = $stmt->fetchAll(); + + $stmtSedes = $pdo->prepare('SELECT s.id, s.nombre FROM sedes s INNER JOIN usuarios_sedes us ON us.sede_id = s.id WHERE us.usuario_id = ?'); + foreach ($usuarios as &$u) { + $stmtSedes->execute([$u['id']]); + $u['sedes'] = $stmtSedes->fetchAll(); + } + + echo json_encode(['ok' => true, 'datos' => $usuarios]); + exit; +} + +// ------------------------------------------------------------ +// DESACTIVAR USUARIO — solo desarrollador +// ------------------------------------------------------------ +if ($accion === 'desactivar_usuario') { + requiereDesarrollador(); + $idDesactivar = $input['id'] ?? 0; + $pdo->prepare('UPDATE usuarios SET activo = 0 WHERE id = ?')->execute([$idDesactivar]); + registrarAuditoria($pdo, 'desactivar', 'usuario', $idDesactivar, 'Se desactivó el acceso de un usuario.'); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// PREVISUALIZAR CAMBIO DE SEDES — solo desarrollador. +// Antes de aplicar el cambio real, le dice al Desarrollador +// cuántos legajos se van a borrar definitivamente por cada +// sede que se le esté quitando al usuario. +// ------------------------------------------------------------ +if ($accion === 'previsualizar_cambio_sedes') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $sedeIdsNuevas = $input['sede_ids'] ?? []; + + $stmtActuales = $pdo->prepare('SELECT sede_id FROM usuarios_sedes WHERE usuario_id = ?'); + $stmtActuales->execute([$usuarioId]); + $sedeIdsActuales = array_column($stmtActuales->fetchAll(), 'sede_id'); + + $sedeIdsQueSeQuitan = array_diff($sedeIdsActuales, $sedeIdsNuevas); + + $detalle = []; + $totalPacientesABorrar = 0; + if (!empty($sedeIdsQueSeQuitan)) { + $stmtSede = $pdo->prepare('SELECT nombre FROM sedes WHERE id = ?'); + $stmtConteo = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE profesional_id = ? AND sede_id = ?'); + foreach ($sedeIdsQueSeQuitan as $sedeId) { + $stmtSede->execute([$sedeId]); + $nombreSede = $stmtSede->fetch()['nombre'] ?? 'Sede desconocida'; + $stmtConteo->execute([$usuarioId, $sedeId]); + $cantidad = (int) $stmtConteo->fetch()['t']; + $totalPacientesABorrar += $cantidad; + $detalle[] = ['sede_id' => (int) $sedeId, 'nombre' => $nombreSede, 'pacientes' => $cantidad]; + } + } + + echo json_encode(['ok' => true, 'sedes_que_se_quitan' => $detalle, 'total_pacientes_a_borrar' => $totalPacientesABorrar]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR SEDES DE UN USUARIO — solo desarrollador. +// Aplica el nuevo conjunto de sedes. Si se le quita una sede +// donde el usuario es profesional y tenía pacientes, esos +// legajos (con sus sesiones, citas y adjuntos) se eliminan +// definitivamente, sin pasar por la papelera. +// ------------------------------------------------------------ +if ($accion === 'actualizar_sedes_usuario') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $sedeIdsNuevas = $input['sede_ids'] ?? []; + + if (empty($sedeIdsNuevas) || !is_array($sedeIdsNuevas)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Elegí al menos una sede para esta persona.']); + exit; + } + + $stmtUsuario = $pdo->prepare('SELECT nombre_completo, rol FROM usuarios WHERE id = ?'); + $stmtUsuario->execute([$usuarioId]); + $usuario = $stmtUsuario->fetch(); + if (!$usuario) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Usuario no encontrado.']); + exit; + } + + $stmtActuales = $pdo->prepare('SELECT sede_id FROM usuarios_sedes WHERE usuario_id = ?'); + $stmtActuales->execute([$usuarioId]); + $sedeIdsActuales = array_column($stmtActuales->fetchAll(), 'sede_id'); + $sedeIdsQueSeQuitan = array_diff($sedeIdsActuales, $sedeIdsNuevas); + + $pdo->beginTransaction(); + try { + // Si es profesional, borrar definitivamente sus legajos + // de cada sede que se le esté quitando. + $pacientesBorrados = 0; + if ($usuario['rol'] === 'profesional' && !empty($sedeIdsQueSeQuitan)) { + $stmtPacientesSede = $pdo->prepare('SELECT id FROM pacientes WHERE profesional_id = ? AND sede_id = ?'); + $stmtBorrarPaciente = $pdo->prepare('DELETE FROM pacientes WHERE id = ?'); + foreach ($sedeIdsQueSeQuitan as $sedeId) { + $stmtPacientesSede->execute([$usuarioId, $sedeId]); + $pacientesDeEstaSede = $stmtPacientesSede->fetchAll(); + foreach ($pacientesDeEstaSede as $p) { + // Las sesiones, citas y adjuntos de cada paciente se + // borran en cascada por las claves foráneas (ON DELETE CASCADE). + $stmtBorrarPaciente->execute([$p['id']]); + $pacientesBorrados++; + } + } + } + + $pdo->prepare('DELETE FROM usuarios_sedes WHERE usuario_id = ?')->execute([$usuarioId]); + $stmtInsertSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIdsNuevas as $sedeId) { + $stmtInsertSede->execute([$usuarioId, $sedeId]); + } + + $pdo->commit(); + + $descripcion = "Se actualizaron las sedes de \"{$usuario['nombre_completo']}\"."; + if ($pacientesBorrados > 0) { + $descripcion .= " Se eliminaron $pacientesBorrados legajo(s) de forma definitiva por quitarle acceso a su sede."; + } + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, $descripcion); + + echo json_encode(['ok' => true, 'pacientes_borrados' => $pacientesBorrados]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo actualizar las sedes del usuario.']); + } + exit; +} + +// ------------------------------------------------------------ +// CAMBIAR MI PROPIO PIN (cualquier usuario logueado, incluido +// el desarrollador) +// ------------------------------------------------------------ +if ($accion === 'cambiar') { + requiereSesion(); + $pinNuevo = trim($input['pin_nuevo'] ?? ''); + if (!pinValido($pinNuevo)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + $hash = password_hash($pinNuevo . APP_SECRET, PASSWORD_BCRYPT); + if (esDesarrollador()) { + $pdo->prepare('UPDATE desarrollador SET clave_hash = ?')->execute([$hash]); + } else { + $pdo->prepare('UPDATE usuarios SET patron_hash = ? WHERE id = ?')->execute([$hash, $_SESSION['usuario_id']]); + } + echo json_encode(['ok' => true, 'mensaje' => 'PIN actualizado.']); + exit; +} + +// ------------------------------------------------------------ +// QUIÉN SOY +// ------------------------------------------------------------ +if ($accion === 'quien_soy') { + requiereSesion(); + $nombreParaMostrar = $_SESSION['nombre_usuario'] ?? ''; + if (($_SESSION['rol'] ?? '') === 'administrativa' && !empty($_SESSION['profesional_activo_id'])) { + $stmt = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmt->execute([$_SESSION['profesional_activo_id']]); + $fila = $stmt->fetch(); + if ($fila) $nombreParaMostrar = $fila['nombre_completo']; + } + echo json_encode([ + 'ok' => true, + 'nombre_usuario' => $nombreParaMostrar, + 'rol' => $_SESSION['rol'] ?? '', + ]); + exit; +} + +if ($accion === 'cerrar_sesion') { + $_SESSION = []; + session_destroy(); + echo json_encode(['ok' => true]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Acción no reconocida.']); diff --git a/api/citas.php b/api/citas.php new file mode 100644 index 0000000..d0b412e --- /dev/null +++ b/api/citas.php @@ -0,0 +1,362 @@ +diff($nacimiento)->y; + } catch (Exception $e) { + return null; + } +} + +function pacientePerteneceAlProfesional($pdo, $pacienteId, $profesionalActivoId) { + $stmt = $pdo->prepare('SELECT 1 FROM pacientes WHERE id = ? AND profesional_id = ?'); + $stmt->execute([$pacienteId, $profesionalActivoId]); + return (bool) $stmt->fetch(); +} + +/** + * Devuelve la cita en choque (si existe) para ese profesional, + * fecha y hora exactas, entre citas pendientes. Si se pasa + * $excluirCitaId, esa cita no se cuenta (permite editar sin + * chocar contra sí misma). + */ +function hayChoqueDeHorario($pdo, $profesionalId, $fecha, $hora, $excluirCitaId = null) { + if (!$hora) return false; + $sql = ' + SELECT c.id, p.nombre, p.apellido FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.fecha = ? AND c.hora = ? AND c.estado = "pendiente" + '; + $params = [$profesionalId, $fecha, $hora]; + if ($excluirCitaId) { + $sql .= ' AND c.id != ?'; + $params[] = $excluirCitaId; + } + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return $stmt->fetch(); +} + +// ------------------------------------------------------------ +// CREAR CITA (POST ?accion=crear) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'crear') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['paciente_id']) || empty($d['fecha'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos de la cita (paciente y fecha son obligatorios).']); + exit; + } + + if (!pacientePerteneceAlProfesional($pdo, $d['paciente_id'], $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $hora = $d['hora'] ?: null; + $choque = hayChoqueDeHorario($pdo, $profesionalActivoId, $d['fecha'], $hora); + if ($choque) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => "Ese horario ya está ocupado con {$choque['nombre']} {$choque['apellido']}. Elegí otro horario."]); + exit; + } + + $token = bin2hex(random_bytes(20)); + + $stmt = $pdo->prepare(' + INSERT INTO citas (paciente_id, profesional_id, fecha, hora, motivo, notas, estado, token_confirmacion) + VALUES (?, ?, ?, ?, ?, ?, "pendiente", ?) + '); + $stmt->execute([ + $d['paciente_id'], + $profesionalActivoId, + $d['fecha'], + $hora, + $d['motivo'] ?? null, + $d['notas'] ?? null, + $token, + ]); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'token_confirmacion' => $token]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR CITA (POST ?accion=actualizar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'actualizar') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el ID de la cita.']); + exit; + } + + $stmtCheck = $pdo->prepare('SELECT * FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$d['id'], $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $hora = $d['hora'] ?: null; + $choque = hayChoqueDeHorario($pdo, $profesionalActivoId, $d['fecha'], $hora, $d['id']); + if ($choque) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => "Ese horario ya está ocupado con {$choque['nombre']} {$choque['apellido']}. Elegí otro horario."]); + exit; + } + + $stmt = $pdo->prepare(' + UPDATE citas SET fecha = ?, hora = ?, motivo = ?, notas = ?, estado = ? + WHERE id = ? + '); + $stmt->execute([ + $d['fecha'], + $hora, + $d['motivo'] ?? null, + $d['notas'] ?? null, + $d['estado'] ?? 'pendiente', + $d['id'], + ]); + echo json_encode(['ok' => true, 'mensaje' => 'Cita actualizada.']); + exit; +} + +// ------------------------------------------------------------ +// CAMBIAR SOLO EL ESTADO (POST ?accion=cambiar_estado) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'cambiar_estado') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id']) || empty($d['estado'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos.']); + exit; + } + if (!in_array($d['estado'], ['pendiente', 'atendida', 'cancelada', 'ausente'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Estado no válido.']); + exit; + } + + $stmtCheck = $pdo->prepare('SELECT 1 FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$d['id'], $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $stmt = $pdo->prepare('UPDATE citas SET estado = ? WHERE id = ?'); + $stmt->execute([$d['estado'], $d['id']]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR CITA (POST ?accion=eliminar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar') { + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + + $stmtCheck = $pdo->prepare('SELECT 1 FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$id, $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $pdo->prepare('DELETE FROM citas WHERE id = ?')->execute([$id]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR PRÓXIMAS CITAS (GET ?accion=proximas&dias=7) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'proximas') { + $dias = (int) ($_GET['dias'] ?? 7); + $stmt = $pdo->prepare(" + SELECT c.*, p.nombre, p.apellido, p.telefono + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? + AND c.fecha BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND c.estado = 'pendiente' + ORDER BY c.fecha ASC, c.hora ASC + "); + $stmt->execute([$profesionalActivoId, $dias]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR CITAS POR RANGO (GET ?accion=rango&desde=&hasta=) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'rango') { + $desde = $_GET['desde'] ?? date('Y-m-01'); + $hasta = $_GET['hasta'] ?? date('Y-m-t'); + $stmt = $pdo->prepare(' + SELECT c.*, p.nombre, p.apellido, p.telefono + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.fecha BETWEEN ? AND ? + ORDER BY c.fecha ASC, c.hora ASC + '); + $stmt->execute([$profesionalActivoId, $desde, $hasta]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// CITAS DE UN PACIENTE (GET ?accion=por_paciente&paciente_id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'por_paciente') { + $id = $_GET['paciente_id'] ?? 0; + + if (!pacientePerteneceAlProfesional($pdo, $id, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $stmt = $pdo->prepare('SELECT * FROM citas WHERE paciente_id = ? ORDER BY fecha DESC, hora DESC'); + $stmt->execute([$id]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PACIENTES INACTIVOS (GET ?accion=inactivos&dias=30) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'inactivos') { + requiereRolProfesional(); + $dias = (int) ($_GET['dias'] ?? 30); + $stmt = $pdo->prepare(" + SELECT p.id, p.nombre, p.apellido, p.dni, p.telefono, + MAX(s.fecha_sesion) AS ultima_sesion, + p.creado_en + FROM pacientes p + LEFT JOIN sesiones s ON s.paciente_id = p.id + WHERE p.profesional_id = ? + GROUP BY p.id + HAVING (ultima_sesion IS NOT NULL AND ultima_sesion < DATE_SUB(CURDATE(), INTERVAL ? DAY)) + OR (ultima_sesion IS NULL AND p.creado_en < DATE_SUB(NOW(), INTERVAL ? DAY)) + ORDER BY ultima_sesion ASC + "); + $stmt->execute([$profesionalActivoId, $dias, $dias]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// CUMPLEAÑOS PRÓXIMOS (GET ?accion=cumpleanios&dias=14) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'cumpleanios') { + $dias = (int) ($_GET['dias'] ?? 14); + $stmt = $pdo->prepare(" + SELECT id, nombre, apellido, telefono, fecha_nacimiento, + DATE_FORMAT(fecha_nacimiento, '%m-%d') AS mes_dia + FROM pacientes + WHERE profesional_id = ? + HAVING mes_dia BETWEEN DATE_FORMAT(CURDATE(), '%m-%d') AND DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d') + OR (DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d') < DATE_FORMAT(CURDATE(), '%m-%d') + AND (mes_dia >= DATE_FORMAT(CURDATE(), '%m-%d') OR mes_dia <= DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d'))) + ORDER BY mes_dia ASC + "); + $stmt->execute([$profesionalActivoId, $dias, $dias, $dias]); + $resultados = $stmt->fetchAll(); + foreach ($resultados as &$r) { + $r['edad_que_cumple'] = calcularEdadCita($r['fecha_nacimiento']) + 1; + } + echo json_encode(['ok' => true, 'datos' => $resultados]); + exit; +} + +// ------------------------------------------------------------ +// AVISOS SIN REVISAR (GET ?accion=avisos_pendientes) +// Cuenta cuántas citas tienen un cambio del paciente (confirmó +// o canceló) que el profesional todavía no vio. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'avisos_pendientes') { + $stmt = $pdo->prepare(' + SELECT COUNT(*) AS total FROM citas + WHERE profesional_id = ? AND revisada_por_profesional = 0 + '); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'total' => (int) $stmt->fetch()['total']]); + exit; +} + +// ------------------------------------------------------------ +// DETALLE DE AVISOS SIN REVISAR (GET ?accion=listar_avisos) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'listar_avisos') { + $stmt = $pdo->prepare(" + SELECT c.id, c.fecha, c.hora, c.estado, c.confirmada_por_paciente, p.nombre, p.apellido + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.revisada_por_profesional = 0 + ORDER BY c.fecha ASC, c.hora ASC + "); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// MARCAR AVISOS COMO VISTOS (POST ?accion=marcar_avisos_vistos) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'marcar_avisos_vistos') { + $stmt = $pdo->prepare('UPDATE citas SET revisada_por_profesional = 1 WHERE profesional_id = ?'); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// RESUMEN DE HOY (GET ?accion=resumen_hoy) +// Cuántas consultas quedan por pasar hoy (la hora de la cita +// todavía no llegó) y a qué hora es la próxima. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'resumen_hoy') { + $stmt = $pdo->prepare(" + SELECT hora FROM citas + WHERE profesional_id = ? AND fecha = CURDATE() AND estado = 'pendiente' + ORDER BY hora ASC + "); + $stmt->execute([$profesionalActivoId]); + $todasHoy = $stmt->fetchAll(); + + $horaActual = date('H:i:s'); + $restantes = array_filter($todasHoy, function ($c) use ($horaActual) { + // Una cita sin hora especificada se cuenta como "restante" + // todo el día, ya que no hay forma de saber si ya pasó. + return $c['hora'] === null || $c['hora'] >= $horaActual; + }); + + echo json_encode([ + 'ok' => true, + 'total_hoy' => count($todasHoy), + 'restantes_hoy' => count($restantes), + 'proxima_hora' => !empty($restantes) ? reset($restantes)['hora'] : null, + ]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/obras_sociales.php b/api/obras_sociales.php new file mode 100644 index 0000000..d248c4d --- /dev/null +++ b/api/obras_sociales.php @@ -0,0 +1,43 @@ +query('SELECT id, nombre FROM obras_sociales ORDER BY es_predefinida DESC, nombre ASC'); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($metodo === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + $nombre = trim($input['nombre'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El nombre de la obra social no puede estar vacío.']); + exit; + } + try { + $stmt = $pdo->prepare('INSERT INTO obras_sociales (nombre, es_predefinida) VALUES (?, 0)'); + $stmt->execute([$nombre]); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'nombre' => $nombre]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { + // Ya existe: la devolvemos igual para que el front la pueda usar + $stmt = $pdo->prepare('SELECT id, nombre FROM obras_sociales WHERE nombre = ?'); + $stmt->execute([$nombre]); + $fila = $stmt->fetch(); + echo json_encode(['ok' => true, 'id' => $fila['id'], 'nombre' => $fila['nombre'], 'ya_existia' => true]); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'Error al guardar la obra social.']); + } + } + exit; +} + +http_response_code(405); +echo json_encode(['ok' => false, 'error' => 'Método no permitido.']);