Gestor de Cita Previa (2)

La versión anterior de Cita Previa, trabaja bajo el concepto de cita para un evento/actividad que tiene una duración fija (sistema médico de salud, gestión administración pública, etc.), donde se estima que es un tiempo promedio fijo para la actividad con el Ciudadano/Cliente.

Esta versión que vengo a explicar y a definir unos componentes para su desarrollo, es también de cita previa, pero desde que se facilita la cita, se establece el tiempo de actividad y por lo tanto no es fijo, si no variable. Puede ser ejemplo de este tipo de actividad un despacho de Odontólogos/Dentistas, un salón de peluquería, masajes o manicura, etc., en aquellas actividades en donde se establece tiempos distintos dependiendo de la actividad que requiera el cliente.

Objetivo

Definir una propuesta técnica/desarrollo de una gestión de cita previa  para actividades que se ejecutan con tiempos distintos y que conllevan la asignación de un profesional para la realización de la actividad.

DEMO: https://fhumanes.com/citas/

Funcionamiento:

  1. – Buscar y seleccionar al Cliente. Si es nuevo, se daría de alta.
  2. – Pulsando en el calendario rojo se va a la pantalla que muestra los datos del usuario y un calendario con los horarios ocupados y asignados a los profesionales.
  3. – Por defecto saldrá el calendario del último profesional que le atendió, pero se puede cambiar en cualquier momento, si al Cliente le interesase o porque el trabajo solicitado lo ejecutase otro profesional.
  4. – Haciendo clic en el día y horario solicitado, se abre la ventana de la cita en la que ya está por defecto el nombre del Cliente y si en la selección del profesional, sólo se fijó uno, también, sale seleccionado ese profesional, Se indica la actividad y con ello, viene el tiempo que se estima para hacer dicha actividad y se actualiza la nueva cita, quedando actualizado calendario y los datos de última cita del Cliente.
  5. – Seleccionando cualquier cita existente del calendario, se podría consultar un modificar, para ajustarla a las nuevas necesidades.

Solución Técnica

La solución se basa en utilización de la librería de FullCalendar, pero sin la integración que ha hecho Xlinesoft en su versión 11 de PHPRunner. El desarrollo está hecho en versión 10.91, pero de la misma forma podría funcionar en versión 11.

Utiliza el Plugin Color, que se puede descargar de la página de los Plugins.

En principio, el ejemplo se ha hecho intentando resolver el problema de la cita previa de una clínica de Odontología, donde hay una recepción de los Clientes y unas citaciones a más o menos largo plazo, para las revisiones y/o limpiezas, pero ni está «cerrado» para este caso, ni para ningún otro, porque el objetivo es mostraros lo sencillo que es trabajar en PHPRunner con esta librería y no es necesario utilizar la integración que ha hecho Xlinesoft en sus ultima versión.

La página que muestra el calendario es una página «Dashboard», hay 3 paneles:

  1. – Panel donde se muestra los datos del Cliente y desde ahí se puede consultar todos sus datos o modificarlos.
  2. – Los filtros de los datos que fijan la información del calendario. Los datos se fijan en Variables de sesión y después se solicita que se refresquen los datos del calendario.
  3. – un Snippet, que carga las librerías de FullCalendar, define y configura FullCalendar, estableciendo su comportamiento y su gestión de todos los eventos que desea tener en el aplicativo. También, se configura la URL del procesos AJAX que recarga los «eventos» de acuerdo a:
    • – La vista y las fechas desde y hasta de los datos de dicha vista.
    • – El contenido de las variables de sesión que fijan el contenido a mostrar de las citas programadas.

Creo que lo que más curiosidad despierta es todo los relacionado con FullCalendar y con el panel del filtro.

Este es el código del Snippet que tiene toda la lógica de FullCalendar.

MyCode/calendario_profesional.php
<?php
$id_master = $_SESSION['cliente']; // ID of Cliente

$html = <<<EOT

<script src='fullcalendar-6.1.8/dist/index.global.min.js'></script>
<script src='fullcalendar-6.1.8/packages/core/locales-all.global.min.js'></script>

<script src='fullcalendar-6.1.8/dist/popper.min.js'></script>
<script src="fullcalendar-6.1.8/dist/tooltip.min.js"></script>


<style>

  #calendar {
    max-width: 1200px;
    margin: 30px auto;
    font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
    font-size: 14px;
  }

/*
i wish this required CSS was better documented :(
https://github.com/FezVrasta/popper.js/issues/674
derived from this CSS on this page: https://popper.js.org/tooltip-examples.html
*/

.popper,
.tooltip {
  position: absolute;
  z-index: 9999;
  background: #3684EE;
  color: black;
  width: 200px;
  border-radius: 3px;
  box-shadow: 0 0 2px rgba(0,0,0,0.5);
  padding: 10px;
  text-align: center;
  opacity: 1;
}
.tooltip-inner {
  background-color: transparent;
}
.style5 .tooltip {
  background: #1E252B;
  color: #FFFFFF;
  max-width: 200px;
  width: auto;
  font-size: .8rem;
  padding: .5em 1em;
}
.popper .popper__arrow,
.tooltip .tooltip-arrow {
  width: 0;
  height: 0;
  border-style: solid;
  position: absolute;
  margin: 5px;
}

.tooltip .tooltip-arrow,
.popper .popper__arrow {
  border-color: #3684EE;
}
.style5 .tooltip .tooltip-arrow {
  border-color: #3684EE;
}
.popper[x-placement^="top"],
.tooltip[x-placement^="top"] {
  margin-bottom: 5px;
}
.popper[x-placement^="top"] .popper__arrow,
.tooltip[x-placement^="top"] .tooltip-arrow {
  border-width: 5px 5px 0 5px;
  border-left-color: transparent;
  border-right-color: transparent;
  border-bottom-color: transparent;
  bottom: -5px;
  left: calc(50% - 5px);
  margin-top: 0;
  margin-bottom: 0;
}
.popper[x-placement^="bottom"],
.tooltip[x-placement^="bottom"] {
  margin-top: 5px;
}
.tooltip[x-placement^="bottom"] .tooltip-arrow,
.popper[x-placement^="bottom"] .popper__arrow {
  border-width: 0 5px 5px 5px;
  border-left-color: transparent;
  border-right-color: transparent;
  border-top-color: transparent;
  top: -5px;
  left: calc(50% - 5px);
  margin-top: 0;
  margin-bottom: 0;
}
.tooltip[x-placement^="right"],
.popper[x-placement^="right"] {
  margin-left: 5px;
}
.popper[x-placement^="right"] .popper__arrow,
.tooltip[x-placement^="right"] .tooltip-arrow {
  border-width: 5px 5px 5px 0;
  border-left-color: transparent;
  border-top-color: transparent;
  border-bottom-color: transparent;
  left: -5px;
  top: calc(50% - 5px);
  margin-left: 0;
  margin-right: 0;
}
.popper[x-placement^="left"],
.tooltip[x-placement^="left"] {
  margin-right: 5px;
}
.popper[x-placement^="left"] .popper__arrow,
.tooltip[x-placement^="left"] .tooltip-arrow {
  border-width: 5px 0 5px 5px;
  border-top-color: transparent;
  border-right-color: transparent;
  border-bottom-color: transparent;
  right: -5px;
  top: calc(50% - 5px);
  margin-left: 0;
  margin-right: 0;
}

</style>

  <div id='calendar'></div>

<script>
  var initialLocaleCode = 'es';
  
  document.addEventListener('DOMContentLoaded', function() {
  var calendarEl = document.getElementById('calendar');

  var calendar = new FullCalendar.Calendar(calendarEl, {
      timeZone: 'UTC+1', // the default 'local' (unnecessary to specify)
      locale: initialLocaleCode,
      height: 'auto',
      // navLinks: true, // can click day/week names to navigate views

      headerToolbar: { center: 'timeGridDay timeGridWeek dayGridMonth Trimestre multiMonthYear' }, // buttons for switching between views
      initialView: 'dayGridMonth',
      views: {
       Trimestre: {
        type: 'multiMonth',
        duration: { months: 3 }
       }
      },
      slotDuration: "00:10:00",
      slotLabelInterval: "00:10:00",
      slotMinTime:  "09:00:00",
      slotMaxTime: "21:00:00",
      weekNumbers: true,
      editable: false,
      multiMonthMinWidth: 50,
      multiMonthMaxColumns: 3,
      weekends: true,
      hiddenDays: [0], 
      dayHeaders: true,
      firstDay: 1,
      selectable: true,
      navLinks: true,

      // ADD previus select Date
      select: function(info) {        
          console.log('Estoy en evento de Select: '+ info.startStr + ' to ' + info.endStr);
          var startDate = info.startStr;
          var  endDate = info.endStr;
          var title = 'Añadir nueva Cita';
          var url = "citas_add.php?masterkey1=$id_master&page=add1&mastertable=pacientes&start="+startDate+"&end="+endDate;
          var header = '<h2 data-itemtype="add_header" data-itemid="add_header">'+ title+'</h2>' ;
          window.popup = Runner.displayPopup({
                url: url,
                width: 800,
                height: 500,
                header: header,
          footer: '<a href="" onclick="window.parent.popup.close();return false;">Close window</a>',
          // footer: '<a href="calendario_de_citas_dashboard.php?qs=$id_master" onclick="window.popup.close();return false;">Close window and Reload page</a>',
          afterCreate: function(popup) {
            window.popup = popup;
          }
        });
      },

       // Evenet managment the Tooltip
      eventDidMount: function(info) {
        console.log("estoy en evento de Tooltip");
        var tooltip = new Tooltip(info.el, {
/* title: info.event._def.title, */
           title: info.event.extendedProps.description, 
/* title: "primera línea <BR> segunda línea", */
          placement: 'top',
           html: true,
          trigger: 'hover',
          container: '#calendar'
        });
      },

      // Load data the Event
      events: { // you can also specify a plain string like 'json/events-for-resources.json'
      url: 'MyCode/calendario_profesional_ajax.php?id_pacientes=$id_master'},
      
      // Event for View and Edit
      eventClick: function(info) {
        console.log("estoy en evento de Click");
        var cita = info.event.id;
        // var title = info.event.title;
        var url = "citas_view.php?page=view1&editid1="+cita;
        var header = '<h2 data-itemtype="view_header" data-itemid="view_header" data-pageid="10">'+'Cita: '+ cita+'</h2>' ;
        window.popup = Runner.displayPopup({
                    url: url,
                    width: 800,
                    height: 500,
                    header: header,
        // footer: '<a href="" onclick="window.win.close();return false;">Close window</a>',
        // footer: '<a href="calendario_list.php?masterkey1=$id_user&page=list&mastertable=incidente_usuario" onclick="window.popup.close();return false;">Close window and Reload page</a>',
        afterCreate: function(popup) {
          window.popup = popup;
        }
       }); 
      },

    });

    calendar.render();
    window.calendar = calendar;
  });

</script>

EOT;

echo $html;

Este es el código al que se llama desde FullCalendar para ir cargando los eventos de acuerdos a los criterios prefijados.

MyCode/calendario_profesional_ajax.php
<?php
require_once("../include/dbcommon.php"); // DataBase PHPRunner


// Require our Event class and datetime utilities
require '../fullcalendar-6.1.8/php/utils.php';

// Short-circuit if the client did not give us a date range.
if (!isset($_GET['start']) || !isset($_GET['end'])) {
  die("Please provide a date range.");
}

$id_paciente = $_GET['id_pacientes']; // id of Cliente

// Parse the start/end parameters.
// These are assumed to be ISO8601 strings with no time nor timeZone, like "2013-12-29".
// Since no timeZone will be present, they will parsed as UTC.
$range_start = parseDateTime($_GET['start']);
$range_end = parseDateTime($_GET['end']);

// Parse the timeZone parameter if it is present.

$time_zone ='UTC';
if (isset($_GET['timeZone'])) {
//   $time_zone = new DateTimeZone($_GET['timeZone']);
}


$start = substr($_GET['start'],0,10).' 00:00:00'; 
$end   = substr($_GET['end']  ,0,10).' 00:00:00';
$now   = date('Y-m-d H:i:s', strtotime(now(). ' -1 day')); // now() - 1 día
if ( $start < $now ) { //  Minimum date of the moment
    //$start = $now;
}
custom_error(1001,"Petición de datos: ".$start.' a: '.$end); // To debug
$language = 'es';

$ids_profesional = $_SESSION['profesional'];

$sql = "
SELECT 
c.id_citas, 
c.pacientes_id,
concat(p.nombre,' ',apellidos) cliente_nombre,
c.Fecha, 
c.HoraInicio, 
c.HoraFin,
concat(c.Fecha,' ',c.HoraInicio) fecha_start,
concat(c.Fecha,' ',c.HoraFin) fecha_end, 
c.notas, 
c.estadocitas_id, 
c.profesional_id,
pr.codigo profesional_codigo,
pr.nombre profesional_nombre,
pr.color profesional_color, 
c.recurso_id, 
r.codigo recurso_codigo,
r.nombre recurso_nombre,
r.color recurso_color,
c.tratamientos_id, 
c.tipos_tratamiento_id,
tt.codigo_tratamiento,
tt.nombre nombre_tratamiento
FROM citas as c
LEFT JOIN pacientes AS p ON (p.id_pacientes = c.pacientes_id)
LEFT JOIN profesional as pr ON (pr.id_profesional = c.profesional_id)
LEFT JOIN recurso as r on (r.id_recurso = c.recurso_id)
LEFT JOIN tipos_tratamiento as tt on (tt.id_tipos_tratamientos = c.tipos_tratamiento_id)
";
if ( $ids_profesional <> null ) {
    $sql .= "where c.profesional_id IN ($ids_profesional) ";
}
$sql .= " and c.fecha >= '$start' and c.Fecha <= '$end' ";


$rs = DB::Query($sql);
// Accumulate an output array of event data arrays.
$output_arrays = array();

while( $data = $rs->fetchAssoc() ){
$color = $data['profesional_color'];

$output_arrays[] = array(
    'id'=>$data['id_citas'],
    'title'=>substr($data['HoraInicio'],0,5).' - '.substr($data['HoraFin'],0,5).' - '.$data['codigo_tratamiento'],
    // 'resourceId'=>$data['recurso_codigo'],
    'color'=>$color,
    'start'=>$data['fecha_start'],
    'end'=>$data['fecha_end'],
    'description'=>'<B>'.$data['codigo_tratamiento'].' - '.date_format(date_create($data['Fecha']),"d/m/Y").' - '.substr($data['HoraInicio'],0,5).' - '.substr($data['HoraFin'],0,5).
        '<BR>'.' Cliente: '.$data['cliente_nombre'].'<BR>'.'Profesional: '.$data['profesional_codigo'].
        '</B>'
      );
}


// Send JSON to the client.
// $str = json_encode($output_arrays, true);
// custom_error(100,"Respuesta de datos JSON: ".$str); // To debug
echo json_encode($output_arrays,true);

?>

El panel del filtro es un recurso que se puede hacer con PHPRunner para hacer formularios que no tienen que está asociados a una tabla física.
En este caso he hecho una vista sobre la tabla «citas» que tiene:

He cambiado el texto del botón guardar y también lo he cambiado de sitio. La programación es:

Programación del Filtro
En evento Process record values

$values['profesional_id'] = $_SESSION['profesional'];
$values['recurso_id'] = $_SESSION['recurso'];

En evento Custom add

$_SESSION['profesional']=$values['profesional_id'];
$_SESSION['recurso']=$values['recurso_id'];

return false;

En evento After record added

$pageObject->setProxyValue("reload", true);

En evento JavaScript Onload event

$('.nav-tabs').hide();  		// Oculta solapa de ADD
$('.alert-success').hide();  	// Oculta Menssage OK
$("div.panel-footer").hide(); // ocultar banda inferiori panel de Filtro

var reload = proxy['reload'];

if (reload == true){
 // Si tienes referencia al calendario:
 window.parent.calendar.refetchEvents();
}

Las primeras líneas son JQUERY para eliminar aspectos visuales de la pantalla y si viene con «reload», lo que hace es solicitar el refresco del panel del calendario.

Creo que también os puede generar curiosidad el alta de la cita desde el calendario. Indicaros que en la tabla de Citas he trabajado con 2 páginas, la de por defecto asociada la forma est´dard de cómo construye las páginas PHPRunner y la de Add1, Edit1 y View1, asociado a la ventana del panel del Snippet del calendario.

Código del ADD de citas
En el evento Process record values

$page = $_GET['page'];
if ($page == 'add1') { // Selección desde Calendar
  $start = $_GET['start'];
  $end = $_GET['end'];
  custom_error(3001,"Fechas de selección: ".print_r($start,true).print_r($end,true)); // To debug
  $timeStart = date("H:i:00",strtotime($start)); // HoraInicio
  $timeStart = substr($timeStart,0,4).'0:00';
  // $timeEnd = date("H:mi:00",strtotime($end)); // HoraFin
  $fecha = date("Y-m-d",strtotime($start));
  $values['Fecha'] = $fecha;
  if ($timeStart > '08:59' && $timeStart < '21:00' ) { // está dentro del horario de trabajo
    $values['HoraInicio'] = $timeStart;
  }

  $profesional = $_SESSION['profesional']; // Recuperamos el profesional

  if (substr_count($profesional,',') == 0 and $profesional <> null ) {
      $values['profesional_id'] = $profesional;
  }
}

En el evento Before Display

$page = $_GET['page'];
if ($page == 'add1') { // se ha hecho alte desde el calendario
  $pageObject->setProxyValue("reload", true);
}

En el evento Before record Added

$minutos = $values['MinutosIntervencion'];
$values['HoraFin'] = date("H:i:s", strtotime($values['HoraInicio'] . " + $minutos minutes"));

unset($values['MinutosIntervencion']);
return true;

En el evento After record added

$id_pacientes = $values['pacientes_id'];
$id_citas = $values['id_citas'];

// Update Pacientes con última Cita
$data = array();
$keyvalues = array();
$data["id_ultima_vista"] = $id_citas;
$keyvalues["id_pacientes"] = $id_pacientes;
DB::Update("pacientes", $data, $keyvalues );

En evento JavaScript Onload Event

var reload = proxy['reload'];
console.log("valor de Reload: ",reload, proxy);

if (proxy.citas_recordAdded && proxy.reload) {  // Estamos en Actualización desde el Calendario
    // Si tienes referencia al calendario:
    window.parent.calendar.refetchEvents();
   // Cerramos la ventana
   window.parent.popup.close();
}

Espero que con este ejemplo haya podido trasladar lo sencillo que es integrar esta y casi cualquier otra librería de Jacascript y me tenéis a vuestra disposición para aclarar cuantas dudas tengais al respecto de cualquier de mis ejemplos o productos que estén publicados en mi blog.

Como siempre, os dejo los fuentes para que los descarguéis, adaptaéis o probéis con aquello que os haya generado curiosidad.

Adjuntos

Archivo Tamaño de archivo Descargas
zip PHPRunner 10.91 y Backup Data Base 532 KB 0

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner