Add server-side pagination to medicos list (25 per page)

list.php now accepts page/limit params and returns total count.
View shows X-Y de N label, page buttons with ellipsis for large ranges,
and prev/next arrows. Search resets to page 1; save/delete stay on
current page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-03 14:24:55 -05:00
co-authored by Claude Sonnet 4.6
parent 00725ccd11
commit 0cef678848
2 changed files with 99 additions and 23 deletions
+28 -14
View File
@@ -6,21 +6,35 @@ header('Content-Type: application/json');
$pdo = Database::getInstance()->getConnection();
$buscar = trim($_GET['q'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
$offset = ($page - 1) * $limit;
$where = '';
$params = [];
if ($buscar !== '') {
$like = '%' . $buscar . '%';
$stmt = $pdo->prepare(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos
WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?
ORDER BY apellidos, nombres"
);
$stmt->execute([$like, $like, $like, $like]);
} else {
$stmt = $pdo->query(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos ORDER BY apellidos, nombres"
);
$like = '%' . $buscar . '%';
$where = "WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?";
$params = [$like, $like, $like, $like];
}
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM medicos $where");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$dataStmt = $pdo->prepare(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos $where
ORDER BY apellidos, nombres
LIMIT $limit OFFSET $offset"
);
$dataStmt->execute($params);
echo json_encode([
'ok' => true,
'data' => $dataStmt->fetchAll(PDO::FETCH_ASSOC),
'total' => $total,
'page' => $page,
'limit' => $limit,
]);
+71 -9
View File
@@ -29,6 +29,23 @@ $API = BASE_URL . 'modules/medicos/api/';
font-size: .75rem; font-weight: 600; }
.acciones { display: flex; gap: 6px; }
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
.pag-bar {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem;
padding: 10px 16px; border-top: 1px solid #e2e8f0; background: #f8fafc;
}
.pag-bar .pag-info { font-size: .8rem; color: #64748b; }
.pag-controls { display: flex; gap: 4px; align-items: center; }
.pag-btn {
min-width: 32px; height: 32px; padding: 0 8px;
border: 1px solid #e2e8f0; border-radius: 7px; background: #fff;
font-size: .82rem; color: #374151; cursor: pointer; font-weight: 500;
display: flex; align-items: center; justify-content: center;
transition: background .12s, border-color .12s;
}
.pag-btn:hover:not(:disabled) { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
.pag-btn.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.pag-btn:disabled { opacity: .4; cursor: not-allowed; }
.pag-ellipsis { font-size: .82rem; color: #94a3b8; padding: 0 4px; line-height: 32px; }
</style>
<div class="page-header">
@@ -60,10 +77,14 @@ $API = BASE_URL . 'modules/medicos/api/';
</tr>
</thead>
<tbody id="tbl-body">
<tr id="tbl-empty"><td colspan="6">Cargando…</td></tr>
<tr id="tbl-empty"><td colspan="7">Cargando…</td></tr>
</tbody>
</table>
</div>
<div class="pag-bar" id="pag-bar" style="display:none">
<span class="pag-info" id="pag-info"></span>
<div class="pag-controls" id="pag-controls"></div>
</div>
</div>
</div>
@@ -150,6 +171,7 @@ $API = BASE_URL . 'modules/medicos/api/';
<script>
const API = '<?= $API ?>';
let _modal, _modalDel, _pendingDelId, _buscarTimer;
let _page = 1, _limit = 25, _total = 0, _q = '';
document.addEventListener('DOMContentLoaded', () => {
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
@@ -157,24 +179,33 @@ document.addEventListener('DOMContentLoaded', () => {
cargar();
});
async function cargar(q = '') {
const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
async function cargar(q = _q, page = _page) {
_q = q;
_page = page;
const params = new URLSearchParams({ page, limit: _limit });
if (q) params.set('q', q);
const res = await fetch(API + 'list.php?' + params);
const json = await res.json();
if (!json.ok) return;
_total = json.total;
_page = json.page;
renderTabla(json.data);
renderPaginacion();
}
function buscar() {
clearTimeout(_buscarTimer);
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim(), 1), 280);
}
function renderTabla(rows) {
const body = document.getElementById('tbl-body');
document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
const desde = (_page - 1) * _limit + 1;
const hasta = Math.min(_page * _limit, _total);
document.getElementById('lbl-total').textContent =
_total ? `${desde}${hasta} de ${_total} médico(s)` : '0 médicos';
if (!rows.length) {
body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
body.innerHTML = '<tr id="tbl-empty"><td colspan="7">Sin resultados.</td></tr>';
return;
}
body.innerHTML = rows.map(m => `
@@ -198,6 +229,37 @@ function renderTabla(rows) {
</tr>`).join('');
}
function renderPaginacion() {
const totalPages = Math.ceil(_total / _limit);
const bar = document.getElementById('pag-bar');
const info = document.getElementById('pag-info');
const ctrl = document.getElementById('pag-controls');
if (totalPages <= 1) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
info.textContent = `Página ${_page} de ${totalPages}`;
// Compute page window: always show first, last, current ±2
const pages = new Set([1, totalPages, _page]);
for (let i = _page - 2; i <= _page + 2; i++) if (i > 0 && i <= totalPages) pages.add(i);
const sorted = [...pages].sort((a, b) => a - b);
let html = '';
html += `<button class="pag-btn" onclick="cargar(_q,${_page-1})" ${_page===1?'disabled':''}>
<i class="fas fa-chevron-left"></i>
</button>`;
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) html += `<span class="pag-ellipsis">…</span>`;
html += `<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(_q,${p})">${p}</button>`;
prev = p;
}
html += `<button class="pag-btn" onclick="cargar(_q,${_page+1})" ${_page===totalPages?'disabled':''}>
<i class="fas fa-chevron-right"></i>
</button>`;
ctrl.innerHTML = html;
}
function abrirModal(m = null) {
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
document.getElementById('modal-alert').classList.add('d-none');
@@ -247,7 +309,7 @@ async function guardar() {
return;
}
_modal.hide();
cargar(document.getElementById('inp-buscar').value.trim());
cargar(_q, _page);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
@@ -272,7 +334,7 @@ async function confirmarEliminar() {
const json = await res.json();
if (json.ok) {
_modalDel.hide();
cargar(document.getElementById('inp-buscar').value.trim());
cargar(_q, _page);
}
} finally {
btn.disabled = false;