Inicio Apuntes FPApuntes DAWDenTera, software de gestión de citas dentales en PHP y MySQL

DenTera, software de gestión de citas dentales en PHP y MySQL

Publicado por
about dentera

Una vez más aquí. Hoy vengo a dejar un pequeño artículo debido a que llevo varios días recuperándome de una operación en la mandíbula. Y como no puedo dormir ni acostarme, recordé que las chicas que trabajan en la clínica dental, toman las citas en PDA (Papel De Apuntar), y me puse a escribir una pequeña aplicación para una «supuesta» clínica dental que permita tomar las citas vía PC. Evidentemente todos estos datos de las citas y demás, se guardan en local, y esto no es nada más que algo para desoxidar PHP, que lo tengo bastante abandonado. Bueno, pues con esta premisa surgió DenTera.

Como decía, DenTera es una aplicación web pensada para clínicas y consultorios que necesitan una agenda de citas dental, un directorio de pacientes y herramientas de presupuesto y facturación, sin depender de plataformas de terceros con costes por sillón o por usuario. Toda la aplicación está desarrollada con algo de CSS, un poquito de JavaScript y la parte gorda en PHP con MySQL. Todo se despliega en un servidor LAMP clásico y el código está organizado para que un desarrollador o un técnico de la clínica pueda entenderla y ampliarla con rapidez.

En las siguientes líneas vamos a resumir cómo funciona DenTera para el día a día de la clínica, qué partes del código son las más importantes y por qué este tipo de arquitectura ayuda tanto al posicionamiento temático (contenido claro sobre “gestión de citas dentales”) como a la mantenibilidad del proyecto a largo plazo.

Qué problema busca resolver una aplicación de gestión de citas dentales

Las clínicas dentales gestionan, al mismo tiempo:

  • Agenda por profesional y por box, con estados (pendiente, confirmada, cancelada, completada).
  • Datos de paciente que se repiten en cada visita (nombre, DNI, teléfono, email).
  • Tratamientos con precios asociados, útiles para presupuestos y para el seguimiento económico.
  • Facturación cuando la visita está cerrada como completada.

DenTera concentra esas necesidades en un solo sistema: una agenda con lista y calendario, ficha e historial por paciente, módulos de equipo y tratamientos, presupuesto por paciente y factura ligada a la cita completada, además de configuración de la clínica y registro de actividad (auditoría básica).

Cómo funciona DenTera

Agenda central (index.php)

dentera inicio

La pantalla principal es la agenda de citas dentales: vista en lista o en calendario mensual, filtros por estado y fechas, y búsqueda que puede actuar sobre nombre, contacto, DNI o incluso una fecha escrita en el cuadro de búsqueda. Desde aquí se accede a crear, editar o eliminar citas, al historial del paciente y a accesos rápidos como “Citas de hoy” o exportación CSV con los mismos filtros que tengas activos.

Un bloque de próximas citas (ventana de catorce días) ayuda a la recepción a ver de un vistazo lo que viene y a abrir el editor de la cita con un clic.

Pacientes (pacientes.php y paciente.php)

ficha cliente

La pantalla de pacientes permite buscar por nombre, DNI, teléfono o email y enlazar al historial o abrir una nueva cita con los datos del paciente ya cargados. En la ficha del paciente conviven el listado cronológico de visitas, estadísticas sencillas (totales, completadas, citas futuras, importe aproximado) y las notas clínicas persistentes (alergias, antecedentes u observaciones que no deberían perderse entre una cita y otra).

Presupuestos y facturas

El presupuesto agrupa las citas de un mismo paciente para ofrecer una visión económica global. La factura se apoya en citas en estado completada, con datos de clínica configurables y desglose de IVA, lista para imprimir o guardar como PDF desde el navegador.

Seguridad y roles

login

El acceso es por sesión PHP, con roles administrador y auxiliar. Las contraseñas se almacenan con hash bcrypt (password_hash / password_verify). Las acciones relevantes pueden dejar constancia en la tabla de auditoría, lo que aporta trazabilidad en entornos clínicos donde importa saber quién modificó qué.

Partes más importantes del código de DenTera

Esta aplicación, se compone por un pequeño conjunto de archivos (php, CSS y JavaScript). Las partes más importantes de esta aplicación vendrían siendo las siguientes:

1. Consulta de citas centralizada (inc/citas_filtro.php)

En lugar de repetir la misma lógica SQL en la página de agenda y en los endpoints AJAX, la función citas_filtro_sql() construye la consulta de citas con JOIN a tratamientos, doctores y pacientes, aplicando los mismos filtros. Eso reduce errores cuando se cambia un filtro: la lista HTML, la tabla actualizada en vivo y la exportación CSV comparten la misma base lógica.

function citas_filtro_sql(array $get): array {
    $busqueda = trim($get['busqueda'] ?? '');
    $estado = $get['estado'] ?? '';
    $fecha = $get['fecha'] ?? '';
    $fecha_desde = $get['fecha_desde'] ?? '';
    $fecha_hasta = $get['fecha_hasta'] ?? '';
    $orden = $get['orden'] ?? 'fecha_asc';
    $orden_dir = $get['dir'] ?? 'asc';

    $sql = "SELECT c.*, t.nombre as tratamiento_nombre, t.precio as tratamiento_precio,
                   d.nombre as doctor_nombre, d.especialidad as doctor_especialidad
            FROM citas c
            LEFT JOIN tratamientos t ON c.tratamiento_id = t.id
            LEFT JOIN doctores d ON c.doctor_id = d.id
            LEFT JOIN pacientes p ON c.paciente_id = p.id
            WHERE 1=1";
    $params = [];

    if ($busqueda !== '') {
        $fechaLiteral = citas_filtro_busqueda_literal_fecha($busqueda);
        $sql .= " AND (c.paciente LIKE ? OR c.telefono LIKE ? OR c.email LIKE ? OR c.dni LIKE ?
                      OR p.nombre LIKE ? OR p.telefono LIKE ? OR p.email LIKE ? OR p.dni LIKE ?";
        $kw = "%{$busqueda}%";
        for ($i = 0; $i < 8; $i++) {
            $params[] = $kw;
        }
        if ($fechaLiteral !== null) {
            $sql .= ' OR c.fecha = ?';
            $params[] = $fechaLiteral;
        }
        $sql .= ')';
    }

    if ($estado !== '') {
        $sql .= " AND c.estado = ?";
        $params[] = $estado;
    }

    if ($fecha !== '') {
        $sql .= " AND c.fecha = ?";
        $params[] = $fecha;
    }

    if ($fecha_desde !== '') {
        $sql .= " AND c.fecha >= ?";
        $params[] = $fecha_desde;
    }

    if ($fecha_hasta !== '') {
        $sql .= " AND c.fecha <= ?";
        $params[] = $fecha_hasta;
    }

    $allowed_ordenes = ['paciente', 'fecha', 'hora', 'doctor', 'motivo'];
    if (!in_array($orden, $allowed_ordenes, true)) {
        $orden = 'fecha';
    }
    if (!in_array($orden_dir, ['asc', 'desc'], true)) {
        $orden_dir = 'asc';
    }

    $sql .= " ORDER BY ";
    switch ($orden) {
        case 'paciente':
            $sql .= "c.paciente $orden_dir";
            break;
        case 'hora':
            $sql .= "c.hora $orden_dir, c.fecha $orden_dir";
            break;
        case 'doctor':
            $sql .= "d.nombre $orden_dir";
            break;
        case 'motivo':
            $sql .= "c.motivo $orden_dir";
            break;
        case 'fecha':
        default:
            $sql .= "c.fecha $orden_dir, (c.orden_agenda IS NULL) ASC, c.orden_agenda ASC, c.hora ASC";
            break;
    }

    return [$sql, $params, $orden, $orden_dir];
}

2. Fragmentos reutilizables (inc/tabla_citas_contenido.php)

La tabla de citas se renderiza desde un include PHP reutilizado por la página principal y por api_tabla_citas.php, que devuelve JSON con el HTML de la tabla. Las funciones auxiliares (por ejemplo traducción de estados o enlaces de ordenación) viven en citas_vista_helpers.php.

        <?php if (count($citas) === 0): ?>
            <div class="empty">
                <div class="empty-icon">&#x1F4C5;</div>
                <h3>No hay citas que mostrar</h3>
                <p><?= $filtrosActivos ? 'Prueba a modificar los filtros de búsqueda.' : 'Haz clic en "Nueva Cita" para agregar la primera.' ?></p>
            </div>
        <?php else: ?>
            <table>
                <thead>
                    <tr>
                        <?php
                        $cols = [
                            ['key' => '_drag', 'label' => '', 'class' => 'citas-drag-th'],
                            ['key' => 'paciente', 'label' => 'Paciente', 'class' => ''],
                            ['key' => 'contacto', 'label' => 'Contacto', 'class' => 'hide-mobile'],
                            ['key' => 'fecha', 'label' => 'Fecha', 'class' => ''],
                            ['key' => 'hora', 'label' => 'Hora', 'class' => ''],
                            ['key' => 'tratamiento', 'label' => 'Tratamiento', 'class' => 'hide-mobile'],
                            ['key' => 'doctor', 'label' => 'Dentista', 'class' => 'hide-mobile'],
                            ['key' => 'motivo', 'label' => 'Motivo', 'class' => ''],
                            ['key' => '', 'label' => 'Estado', 'class' => ''],
                            ['key' => '', 'label' => '', 'class' => ''],
                        ];
                        foreach ($cols as $col):
                            if ($col['key'] === '_drag'):
                        ?>
                            <th class="<?= $col['class'] ?>" scope="col" aria-label="Reordenar filas"></th>
                        <?php elseif ($col['key'] === '' || $col['key'] === 'contacto' || $col['key'] === 'tratamiento'):
                        ?>
                            <th class="<?= $col['class'] ?>"><?= $col['label'] ?></th>
                        <?php else:
                            $s = sortUrl($col['key'], $orden, $orden_dir, $get);
                        ?>
                            <th class="<?= $col['class'] ?>">
                                <a href="<?= $s['url'] ?>" class="sort-link"><?= $col['label'] ?><?= $s['arrow'] ?></a>
                            </th>
                        <?php endif; endforeach; ?>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($citas as $c): ?>
                    <tr data-cita-id="<?= (int) $c['id'] ?>" data-fecha="<?= htmlspecialchars($c['fecha'], ENT_QUOTES, 'UTF-8') ?>">
                        <td class="<?= $orden === 'fecha' ? 'citas-drag-cell' : 'citas-drag-spacer' ?>">
                            <?php if ($orden === 'fecha'): ?>
                            <span class="citas-drag-handle" title="Arrastrar para ordenar (solo entre citas del mismo día)">&#8942;&#8942;</span>
                            <?php endif; ?>
                        </td>
                        <td>
                            <div class="patient-cell">
                                <div class="patient-avatar" style="background:<?= colorAvatar($c['paciente']) ?>">
                                    <?= iniciales($c['paciente']) ?>
                                </div>
                                <div>
                                    <a href="paciente.php?id=<?= $c['paciente_id'] ?: '#' ?>" class="patient-name-link"><?= htmlspecialchars($c['paciente']) ?></a>
                                    <?php if ($c['dni']): ?>
                                        <div class="patient-dni"><?= htmlspecialchars($c['dni']) ?></div>
                                    <?php endif; ?>
                                </div>
                            </div>
                        </td>
                        <td class="hide-mobile">
                            <div class="contact-cell">
                                <?php if ($c['telefono']): ?>
                                    <span class="contact-item"><span class="contact-icon">&#x1F4DE;</span> <?= htmlspecialchars($c['telefono']) ?></span>
                                <?php endif; ?>
                                <?php if ($c['email']): ?>
                                    <span class="contact-item"><span class="contact-icon">&#x2709;</span> <?= htmlspecialchars($c['email']) ?></span>
                                <?php endif; ?>
                            </div>
                        </td>
                        <td>
                            <div class="date-cell">
                                <span class="date-main"><?= date('d/m/Y', strtotime($c['fecha'])) ?></span>
                                <span class="date-day"><?= diaSemana($c['fecha']) ?></span>
                            </div>
                        </td>
                        <td>
                            <span class="time-badge">&#x1F550; <?= date('H:i', strtotime($c['hora'])) ?></span>
                        </td>
                        <td class="hide-mobile">
                            <?php if ($c['tratamiento_nombre']): ?>
                                <span class="treatment-badge"><?= htmlspecialchars($c['tratamiento_nombre']) ?></span>
                            <?php else: ?>
                                <span style="color:var(--gray-400);font-size:0.8rem;">—</span>
                            <?php endif; ?>
                        </td>
                        <td class="hide-mobile">
                            <?php if ($c['doctor_nombre']): ?>
                                <span><?= htmlspecialchars($c['doctor_nombre']) ?></span>
                                <?php if ($c['doctor_especialidad']): ?>
                                    <div style="font-size:0.75rem;color:var(--gray-400);"><?= htmlspecialchars($c['doctor_especialidad']) ?></div>
                                <?php endif; ?>
                            <?php else: ?>
                                <span style="color:var(--gray-400);font-size:0.8rem;">—</span>
                            <?php endif; ?>
                        </td>
                        <td><?= htmlspecialchars(mb_strlen($c['motivo']) > 35 ? mb_substr($c['motivo'], 0, 35) . '...' : $c['motivo']) ?></td>
                        <td>
                            <span class="badge badge-<?= $c['estado'] ?>">
                                <span class="badge-dot"></span>
                                <?= traduccion($c['estado']) ?>
                            </span>
                        </td>
                        <td>
                            <div class="actions">
                                <a href="editar.php?id=<?= $c['id'] ?>" class="btn btn-warning btn-sm">Editar</a>
                                <a href="duplicar_cita.php?id=<?= $c['id'] ?>" class="btn btn-outline btn-sm" title="Copiar datos en una cita nueva">Duplicar</a>
                                <a href="eliminar.php?id=<?= $c['id'] ?>" class="btn btn-danger btn-sm"
                                   onclick="return confirm('¿Eliminar la cita de <?= htmlspecialchars(addslashes($c['paciente'])) ?>?')">Eliminar</a>
                                <a href="presupuesto.php?paciente=<?= urlencode($c['paciente']) ?>" class="btn btn-primary btn-sm" title="Ver presupuesto">&#x1F4B0;</a>
                                <?php if ($c['estado'] === 'completada'): ?>
                                    <a href="factura.php?id=<?= $c['id'] ?>" class="btn btn-success btn-sm" title="Ver factura">&#x1F9FE;</a>
                                <?php endif; ?>
                            </div>
                        </td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>

3. Búsqueda en vivo y rutas robustas (js/agenda-live.js)

El script de agenda usa fetch contra el API con debounce (espera breve tras dejar de escribir) para no saturar el servidor. Las URLs se resuelven respecto a la ruta base del proyecto, lo que evita fallos si la aplicación se instala en una subcarpeta (ej. /clinica/).

(function () {
    var form = document.getElementById('form-filtros-agenda');
    var wrap = document.getElementById('tabla-citas-agenda');
    var input = document.getElementById('input-busqueda-agenda');
    if (!form || !wrap || !input) return;

    var debounceMs = 280;
    var t = null;
    var seq = 0;

    function mergeLocationParams(params) {
        var u = new URLSearchParams(window.location.search);
        ['orden', 'dir', 'mes', 'anio', 'fecha'].forEach(function (k) {
            if (!params.has(k) && u.has(k)) {
                params.set(k, u.get(k));
            }
        });
    }

    function queryFromForm() {
        var fd = new FormData(form);
        var params = new URLSearchParams(fd);
        mergeLocationParams(params);
        return params.toString();
    }

    function refreshTable() {
        var my = ++seq;
        wrap.classList.add('table-loading');
        var q = queryFromForm();
        var api =
            typeof window.DENTISTA_API_TABLA_CITAS === 'string' && window.DENTISTA_API_TABLA_CITAS
                ? window.DENTISTA_API_TABLA_CITAS
                : 'api_tabla_citas.php';
        fetch(api + '?' + q, {
            method: 'GET',
            credentials: 'same-origin',
            headers: { Accept: 'application/json' },
        })
            .then(function (r) {
                if (!r.ok) throw new Error('HTTP ' + r.status);
                return r.json();
            })
            .then(function (data) {
                if (my !== seq) return;
                if (typeof data.html === 'string') {
                    wrap.innerHTML = data.html;
                    if (typeof window.initCitasAgendaSort === 'function') {
                        window.initCitasAgendaSort();
                    }
                }
            })
            .catch(function () {
                if (my !== seq) return;
            })
            .finally(function () {
                if (my === seq) wrap.classList.remove('table-loading');
            });
    }

    function scheduleRefresh() {
        clearTimeout(t);
        t = setTimeout(refreshTable, debounceMs);
    }

    input.addEventListener('input', scheduleRefresh);

    ['estado', 'fecha_desde', 'fecha_hasta'].forEach(function (name) {
        var el = form.querySelector('[name="' + name + '"]');
        if (el) el.addEventListener('change', scheduleRefresh);
    });
})();

4. Orden manual con persistencia (api_reordenar_citas.php)

El orden visual puede guardarse en la columna orden_agenda, actualizada mediante SortableJS. El orden se respeta cuando la tabla está organizada por fecha, manteniendo la coherencia de la agenda diaria.

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['ok' => false, 'error' => 'Método no permitido'], JSON_UNESCAPED_UNICODE);
    exit;
}

$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!is_array($data) || empty($data['byFecha']) || !is_array($data['byFecha'])) {
    http_response_code(400);
    echo json_encode(['ok' => false, 'error' => 'Datos inválidos'], JSON_UNESCAPED_UNICODE);
    exit;
}

try {
    $pdo->beginTransaction();
    foreach ($data['byFecha'] as $fecha => $ids) {
        if (!is_string($fecha) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
            throw new RuntimeException('Fecha inválida');
        }
        if (!is_array($ids)) {
            throw new RuntimeException('Lista de citas inválida');
        }
        $orden = 0;
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id <= 0) {
                continue;
            }
            $stmt = $pdo->prepare('SELECT id, fecha FROM citas WHERE id = ?');
            $stmt->execute([$id]);
            $row = $stmt->fetch();
            if (!$row || $row['fecha'] !== $fecha) {
                throw new RuntimeException('La cita no coincide con el día indicado');
            }
            $pdo->prepare('UPDATE citas SET orden_agenda = ? WHERE id = ?')->execute([$orden, $id]);
            $orden += 10;
        }
    }
    $pdo->commit();
    registrarAccion($pdo, 'reordenar', 'citas', null, 'Orden manual de citas en la agenda');
    echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    if (str_contains($e->getMessage(), 'orden_agenda')) {
        http_response_code(503);
        echo json_encode([
            'ok' => false,
            'error' => 'Falta la columna orden_agenda en la base de datos. Ejecuta schema_update_orden_agenda.sql',
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }
    http_response_code(500);
    echo json_encode(['ok' => false, 'error' => 'Error al guardar el orden'], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    http_response_code(400);
    echo json_encode(['ok' => false, 'erro

5. Esquema SQL con migración idempotente (schema.sql)

El archivo SQL incluye bloques que consultan INFORMATION_SCHEMA y solo ejecutan ALTER o CREATE INDEX si falta el elemento. Esto facilita actualizar clínicas existentes sin romper la base de datos.

6. Doble reserva y reglas de negocio

Al guardar una cita, el sistema comprueba que el doctor no tenga otra cita pendiente o confirmada en la misma franja horaria. Estas reglas en el lado del servidor son vitales para evitar conflictos en la recepción.

Stack técnico en resumen

Todo esto lo he creado utilizando las siguientes tecnologías:

CapaTecnología
ServidorPHP 8+, sesiones nativas
DatosMySQL / MariaDB, PDO preparado
InterfazHTML, CSS propio, JavaScript vanilla + SortableJS
DespliegueServidor LAMP (Apache/Nginx); importación de schema.sql

Conclusión sobre DenTera

DenTera es una solución ligera de software para la gestión de citas dentales. Su arquitectura basada en filtros SQL únicos, APIs mínimas y migraciones idempotentes lo convierte en una herramienta excelente tanto para clínicas que buscan independencia digital como para desarrolladores que necesitan una base sólida para personalizar.

Si quieres probar esta aplicación en tu propio equipo para verla, o modificarla a tu gusto, te puedes descargar todo el código completo y la base de datos para que todo funcione, desde el repositorio en GitHub en el que alojé el proyecto.

También te puede interesar ...

Deja un comentario

* Al utilizar este formulario, aceptas que este sitio web almacene y maneje tus datos.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Adblock Detectado!!

Ayúdanos deshabilitando la extensión AdBlocker de tu navegador para visitar esta web.
Si no sabes hacerlo en Chrome, consulta el siguiente enlace. Si utilizas Firefox, puedes consultar este otro enlace.
Esto mejorará tu experiencia en este sitio web.