src/Controller/AppointmentController.php line 274

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Appointment;
  4. use App\Entity\Horario
  5. use App\Form\AppointmentType;
  6. use App\Form\RescheduleAppointmentType;
  7. use App\Repository\AppointmentRepository;
  8. use App\Repository\HorarioRepository;
  9. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\Routing\Annotation\Route;
  13. use Doctrine\ORM\EntityManagerInterface;
  14. use Symfony\Component\HttpFoundation\JsonResponse;
  15. use App\Service\AppointmentNotifier;
  16. use App\Service\ZonitelSmsService;
  17. /**
  18.  * @Route("admin/appointment")
  19.  */
  20. class AppointmentController extends AbstractController
  21. {
  22.     private $em;
  23.     private AppointmentNotifier $appointmentNotifier;
  24.     private $appointmentNotifierSMS;
  25.     public function __construct(EntityManagerInterface $emAppointmentNotifier $appointmentNotifierZonitelSmsService $appointmentNotifierSMS)
  26.     {
  27.         $this->em $em;
  28.         $this->appointmentNotifier $appointmentNotifier;
  29.         $this->appointmentNotifierSMS $appointmentNotifierSMS;
  30.         
  31.     }
  32.     /**
  33.      * @Route("/new", name="admin_appointment_new", methods={"GET", "POST"})
  34.      */
  35.     public function new(Request $requestEntityManagerInterface $entityManagerHorarioRepository $horarioRepo): Response
  36.     {
  37.         $appointment = new Appointment();
  38.     
  39.         // FORZAR ESTADO VÁLIDO ANTES de crear el formulario
  40.         $appointment->setStatus('confirmada');
  41.         $appointment->setPaymentMethod('imaging_pro');
  42.     
  43.         // Asignar automáticamente el admin como proveedor
  44.         $appointment->setProvider($this->getUser());
  45.     
  46.         $form $this->createForm(AppointmentType::class, $appointment, [
  47.             'provider' => null,
  48.             'is_edit' => false,
  49.             'context' => 'admin'
  50.         ]);
  51.         
  52.         $form->handleRequest($request);
  53.     
  54.         if ($form->isSubmitted() && $form->isValid()) {
  55.             try {
  56.                 
  57.                 $appointment->setProvider($this->getUser());
  58.             
  59.                 // Verificar y asegurar el estado nuevamente
  60.                 if (!$appointment->getStatus()) {
  61.                     $appointment->setStatus('confirmada');
  62.                 }
  63.         
  64.                 $appointment->setPaymentMethod('imaging_pro');
  65.     
  66.                 // Obtener el horario directamente del appointment
  67.                 $horario $appointment->getHorario();
  68.                 
  69.                 // Verificar que el horario existe
  70.                 if (!$horario) {
  71.                     $this->addFlash('error''El horario seleccionado no es válido.');
  72.                     return $this->redirectToRoute('admin_appointment_new');
  73.                 }
  74.     
  75.                 // Verificar que el horario está disponible
  76.                 if ($horario->getEstado() !== 'disponible') {
  77.                     $this->addFlash('error''El horario seleccionado no está disponible.');
  78.                     return $this->redirectToRoute('admin_appointment_new');
  79.                 }
  80.     
  81.                 // Actualizar el estado del horario
  82.                 $horario->setEstado('ocupado');
  83.                 
  84.                 // Persistir
  85.                 $entityManager->persist($appointment);
  86.         
  87.                 // Flush
  88.                 $entityManager->flush();
  89.     
  90.                
  91.                 $language $request->getSession()->get('_locale''es'); 
  92.         
  93.                 // Notificar si el servicio está disponible
  94.                 if ($this->appointmentNotifier) {
  95.                     $this->appointmentNotifier->sendAppointmentNotification($appointment'create'$language);
  96.                 }
  97.                 
  98.                 // Notificar via sms
  99.                 try {
  100.                     $patient $appointment->getPatient();
  101.                     if ($patient && $patient->getPhone()) {
  102.                         $to $patient->getPhone();
  103.                         $patientName $patient->getName() . ' ' $patient->getLastName();
  104.                         
  105.                         // OBTENER FECHA Y HORA DESDE EL HORARIO
  106.                         $horario $appointment->getHorario();
  107.                         $fechaHorario $horario->getFecha();
  108.                         $horaInicio $horario->getHora();
  109.                         
  110.                         // Formatear fecha y hora
  111.                         $appointmentDate $fechaHorario->format('m/d/Y');
  112.                         $appointmentTime $horaInicio->format('H:i');
  113.         
  114.                         // Así se usa - CORRECTO ✅
  115.                         $text $this->appointmentNotifierSMS->getAppointmentConfirmationText($patientName$appointmentDate$appointmentTime);
  116.                         
  117.                         
  118.                         $this->appointmentNotifierSMS->sendQuickMessage($to$text);
  119.                         $this->addFlash('success''SMS de confirmación enviado correctamente.');
  120.                     } else {
  121.                         $this->addFlash('warning''Cita creada pero no se pudo enviar SMS: paciente sin teléfono registrado.');
  122.                     }
  123.                 } catch (\Exception $smsException) {
  124.                     // Log del error pero no interrumpir el flujo
  125.                     error_log("Error enviando SMS: " $smsException->getMessage());
  126.                     $this->addFlash('warning''Cita creada pero hubo un error al enviar el SMS de confirmación.');
  127.                 }
  128.                 
  129.     
  130.                 $this->addFlash('success''Cita creada correctamente.');
  131.                 return $this->redirectToRoute('admin_appointment', [], Response::HTTP_SEE_OTHER);
  132.     
  133.             } catch (\Exception $e) {
  134.                 $this->addFlash('error''Error al crear la cita: ' $e->getMessage());
  135.             }
  136.         } elseif ($form->isSubmitted() && !$form->isValid()) {
  137.             // Mostrar errores de validación
  138.             $errors = [];
  139.             foreach ($form->getErrors(true) as $error) {
  140.                 $errors[] = $error->getMessage();
  141.             }
  142.             $this->addFlash('error''Errores en el formulario: ' implode(', '$errors));
  143.         }
  144.     
  145.         return $this->render('admin/appointment/form.html.twig', [
  146.             'appointment' => $appointment,
  147.             'form' => $form->createView(),
  148.             'modo' => 'new',
  149.             'seccion' => 'appointments',
  150.             'titulo_pagina' => 'Nueva Cita'
  151.         ]);
  152.     }
  153.     
  154.     private function getAppointmentConfirmationText(string $patientNamestring $datestring $time): string
  155.     {
  156.         $nameParts explode(' '$patientName);
  157.         $apellido end($nameParts);
  158.         
  159.         $template "Imaging Pro\n" .
  160.                     "Sr./Sra. %s, confirmamos su cita médica: %s a las %s\n" .
  161.                     "800 W Airport Fwy, Suite 1033\n" .
  162.                     "Estacionar atrás gratis\n" .
  163.                     "Elevador al piso 10";
  164.         
  165.         return sprintf($template$apellido$date$time);
  166.     }
  167.     /**
  168.      * @Route("/calendar", name="admin_appointment_calendar", methods={"GET"})
  169.      */
  170.     public function calendar(): Response
  171.     {
  172.         return $this->render('admin/appointment/calendar.html.twig', [
  173.             'seccion' => 'appointments',
  174.             'titulo_pagina' => 'Calendario de Citas'
  175.         ]);
  176.     }
  177.     // /**
  178.     //  * @Route("/events", name="admin_appointment_events", methods={"GET"})
  179.     //  */
  180.     // public function events(Request $request, AppointmentRepository $repo): JsonResponse
  181.     // {
  182.     //     $start = new \DateTime($request->query->get('start'));
  183.     //     $end = new \DateTime($request->query->get('end'));
  184.     //     $mode = $request->query->get('mode', 'preview');
  185.     //     $appointments = $repo->findBetween($start, $end);
  186.     //     $estadoColorMap = [
  187.     //         'confirmada' => '#28a745',
  188.     //         'cancelada_paciente' => '#dc3545',
  189.     //         'cancelada_clinica' => '#842029',
  190.     //         'completada' => '#0d6efd',
  191.     //         'ausente' => '#6c757d',
  192.     //     ];
  193.     //     $grouped = [];
  194.     //     foreach ($appointments as $a) {
  195.     //         $horario = $a->getHorario();
  196.     //         if (!$horario || !$horario->getFecha() || !$horario->getHora()) {
  197.     //             continue;
  198.     //         }
  199.     //         $fecha = $horario->getFecha()->format('Y-m-d');
  200.     //         $hora  = $horario->getHora();
  201.     //         $startDateTime = (new \DateTime())
  202.     //             ->setDate(
  203.     //                 (int)$horario->getFecha()->format('Y'),
  204.     //                 (int)$horario->getFecha()->format('m'),
  205.     //                 (int)$horario->getFecha()->format('d')
  206.     //             )
  207.     //             ->setTime(
  208.     //                 (int)$hora->format('H'),
  209.     //                 (int)$hora->format('i'),
  210.     //                 (int)$hora->format('s')
  211.     //             );
  212.     //         // Duración dinámica desde la configuración del horario
  213.     //         $duracion = method_exists($horario, 'getDuracion') && is_numeric($horario->getDuracion())
  214.     //             ? (int)$horario->getDuracion()
  215.     //             : 30; // valor por defecto en minutos
  216.     //         $endDateTime = (clone $startDateTime)->modify("+{$duracion} minutes");
  217.     //         $endOfDay = (clone $startDateTime)->setTime(23, 59, 59);
  218.     //         if ($endDateTime > $endOfDay) {
  219.     //             $endDateTime = $endOfDay;
  220.     //         }
  221.     //         $event = [
  222.     //             'title' => sprintf('🩺 %s', $a->getPatient()->getName()),
  223.     //             'start' => $startDateTime->format('Y-m-d\TH:i:s'),
  224.     //             'end'   => $endDateTime->format('Y-m-d\TH:i:s'),
  225.     //             'backgroundColor' => $estadoColorMap[$a->getStatus()] ?? '#000000',
  226.     //             'status' => $a->getStatus(),
  227.     //         ];
  228.     //         $grouped[$fecha][] = $event;
  229.     //     }
  230.     //     if ($mode === 'full') {
  231.     //         // Devolver todos los eventos planos (sin "Ver más")
  232.     //         return new JsonResponse(array_merge(...array_values($grouped)));
  233.     //     }
  234.     //     // Modo preview (solo para vista mensual)
  235.     //     $response = [];
  236.     //     foreach ($grouped as $day => $events) {
  237.     //         $response[] = [
  238.     //             'date' => $day,
  239.     //             'preview' => array_slice($events, 0, 1),
  240.     //             'total' => count($events),
  241.     //             'hasMore' => count($events) > 1,
  242.     //         ];
  243.     //     }
  244.     //     return new JsonResponse($response);
  245.     // }
  246.  
  247.     /**
  248.      * @Route("/events", name="admin_appointment_events", methods={"GET"})
  249.      */
  250.     public function events(Request $requestAppointmentRepository $repo): JsonResponse
  251.     {
  252.         $start = new \DateTime($request->query->get('start'));
  253.         $end = new \DateTime($request->query->get('end'));
  254.         $mode $request->query->get('mode''preview');
  255.         $appointments $repo->findBetween($start$end);
  256.         // MAPEO PROFESIONAL DE TIPOS DE ESTUDIO
  257.         $studyTypeMapping = [
  258.             'rayos-x' => 'Rayos X Digital',
  259.             'ultrasonido'=> 'Ultrasonido 4D',
  260.             'tomografia'=> 'Tomografía Computarizada'
  261.             'resonancia'=> 'Resonancia Magnética',
  262.             'mamografia'=> 'Mamografía Digital',
  263.             'nuclear'=> 'Medicina Nuclear',
  264.             'radiografia'=> 'Radiografía',
  265.             'ultrasonido-abdominal'=> 'Ultrasonido abdominal',
  266.             'ultrasonido-mama'=> 'Ultrasonido de mama',
  267.             'ecocardiograma'=> 'Ecocardiograma',
  268.             'ultrasonido-musculoesqueletico'=> 'Ultrasonido musculoesquelético',
  269.             'ultrasonido-obstetrico'=> 'Ultrasonido obstétrico',
  270.             'arteriografia-venografia'=> 'Arteriografía y venografía',
  271.             'ultrasonido-intravascular'=> 'Ultrasonido intravascular (IVU)',
  272.             'ultrasonido-vascular-periferico'=> 'Ultrasonido vascular periférico',
  273.             'ultrasonido-vascular'=> 'Ultrasonido vascular',
  274.             'ultrasonido-pelvico'=> '>Ultrasonido pélvico',
  275.             'ultrasonido-prostata'=> 'Ultrasonido de próstata',
  276.             'ultrasonido-renal'=> 'Ultrasonido renal',
  277.             'ultrasonido-escrotal'=> 'Ultrasonido escrotal',
  278.             'ultrasonido-tiroideo'=> 'Ultrasonido tiroideo'
  279.         ];
  280.         $events = [];
  281.         foreach ($appointments as $a) {
  282.             $horario $a->getHorario();
  283.             if (!$horario || !$horario->getFecha() || !$horario->getHora()) {
  284.                 continue;
  285.             }
  286.             $fecha $horario->getFecha()->format('Y-m-d');
  287.             $hora  $horario->getHora();
  288.             $startDateTime = (new \DateTime())
  289.                 ->setDate(
  290.                     (int)$horario->getFecha()->format('Y'),
  291.                     (int)$horario->getFecha()->format('m'),
  292.                     (int)$horario->getFecha()->format('d')
  293.                 )
  294.                 ->setTime(
  295.                     (int)$hora->format('H'),
  296.                     (int)$hora->format('i'),
  297.                     (int)$hora->format('s')
  298.                 );
  299.             // Duración dinámica desde la configuración del horario
  300.             $duracion method_exists($horario'getDuracion') && is_numeric($horario->getDuracion())
  301.                 ? (int)$horario->getDuracion()
  302.                 : 30;
  303.             $endDateTime = (clone $startDateTime)->modify("+{$duracion} minutes");
  304.             $endOfDay = (clone $startDateTime)->setTime(235959);
  305.             if ($endDateTime $endOfDay) {
  306.                 $endDateTime $endOfDay;
  307.             }
  308.             $patient $a->getPatient();
  309.             
  310.             // OBTENER Y FORMATEAR EL TIPO DE ESTUDIO
  311.             $rawStudyType method_exists($a'getTipoEstudio') ? $a->getTipoEstudio() : null;
  312.             $formattedStudyType $this->formatStudyType($rawStudyType$studyTypeMapping);
  313.             $event = [
  314.                 'id' => $a->getId(),
  315.                 'title' => $patient $this->formatPatientName($patient->getName()) : 'Paciente no especificado',
  316.                 'start' => $startDateTime->format('Y-m-d\TH:i:s'),
  317.                 'end' => $endDateTime->format('Y-m-d\TH:i:s'),
  318.                 'date' => $fecha,
  319.                 'time' => $hora->format('H:i'),
  320.                 'duration' => $duracion,
  321.                 'status' => $a->getStatus(),
  322.                 'patient' => $patient ? [
  323.                     'id' => $patient->getId(),
  324.                     'name' => $this->formatPatientName($patient->getName()),
  325.                     'email' => method_exists($patient'getEmail') ? $patient->getEmail() : null,
  326.                     'phone' => method_exists($patient'getPhone') ? $patient->getPhone() : null,
  327.                 ] : null,
  328.                 'professional' => method_exists($a'getProfessional') ? [
  329.                     'id' => $a->getProfessional()->getId(),
  330.                     'name' => $this->formatProfessionalName($a->getProfessional()->getName()),
  331.                 ] : null,
  332.                 'notes' => method_exists($a'getNotes') ? $a->getNotes() : null,
  333.                 'service' => $formattedStudyType,
  334.                 'raw_service' => $rawStudyType// Mantener el original para referencia
  335.             ];
  336.             $events[] = $event;
  337.         }
  338.         if ($mode === 'grouped') {
  339.             $grouped = [];
  340.             foreach ($events as $event) {
  341.                 $grouped[$event['date']][] = $event;
  342.             }
  343.             
  344.             $response = [];
  345.             foreach ($grouped as $day => $dayEvents) {
  346.                 $response[] = [
  347.                     'date' => $day,
  348.                     'appointments' => $dayEvents,
  349.                     'total' => count($dayEvents),
  350.                 ];
  351.             }
  352.             
  353.             return new JsonResponse($response);
  354.         }
  355.         return new JsonResponse([
  356.             'success' => true,
  357.             'total' => count($events),
  358.             'appointments' => $events,
  359.             'date_range' => [
  360.                 'start' => $start->format('Y-m-d'),
  361.                 'end' => $end->format('Y-m-d')
  362.             ]
  363.         ]);
  364.     }
  365.     /**
  366.      * Formatea el tipo de estudio según el mapeo profesional - CORREGIDO
  367.      */
  368.     private function formatStudyType(?string $rawStudyType, array $mapping): string
  369.     {
  370.         if (empty($rawStudyType)) {
  371.             return 'Consulta General'// Valor por defecto
  372.         }
  373.         // Normalizar el texto para comparación EXACTA
  374.         $normalized strtolower(trim($rawStudyType));
  375.         $normalized preg_replace('/[^a-z0-9\-]/''-'$normalized);
  376.         
  377.         // Buscar coincidencia EXACTA primero
  378.         if (isset($mapping[$normalized])) {
  379.             return $mapping[$normalized];
  380.         }
  381.         
  382.         // Si no hay coincidencia exacta, buscar por coincidencia parcial PERO con prioridad
  383.         // Ordenar por longitud de clave (más específico primero) para evitar que "ultrasonido" genérico capture todo
  384.         $sortedKeys array_keys($mapping);
  385.         usort($sortedKeys, function($a$b) {
  386.             return strlen($b) - strlen($a); // Más largo primero
  387.         });
  388.         
  389.         foreach ($sortedKeys as $key) {
  390.             if (strpos($normalized$key) !== false) {
  391.                 return $mapping[$key];
  392.             }
  393.         }
  394.         
  395.         // Si no se encuentra, formatear el texto original
  396.         return $this->formatFallbackStudyType($rawStudyType);
  397.     }
  398.     /**
  399.      * Formatea nombres de estudio que no están en el mapeo
  400.      */
  401.     private function formatFallbackStudyType(string $studyType): string
  402.     {
  403.         // Limpiar y capitalizar
  404.         $cleaned trim($studyType);
  405.         $cleaned ucwords(strtolower($cleaned));
  406.         
  407.         // Reemplazar caracteres no deseados
  408.         $cleaned preg_replace('/[^a-zA-Z0-9áéíóúÁÉÍÓÚñÑ\s]/'' '$cleaned);
  409.         $cleaned preg_replace('/\s+/'' '$cleaned);
  410.         
  411.         return $cleaned;
  412.     }
  413.     /**
  414.      * Formatea el nombre del paciente
  415.      */
  416.     private function formatPatientName(?string $name): string
  417.     {
  418.         if (empty($name)) {
  419.             return 'Paciente no especificado';
  420.         }
  421.         
  422.         return ucwords(strtolower(trim($name)));
  423.     }
  424.     /**
  425.      * Formatea el nombre del profesional
  426.      */
  427.     private function formatProfessionalName(?string $name): string
  428.     {
  429.         if (empty($name)) {
  430.             return 'Profesional no asignado';
  431.         }
  432.         
  433.         // Asumir formato "Dr. Juan Pérez" o similar
  434.         $formatted trim($name);
  435.         
  436.         // Si no tiene título, agregar "Dr." por defecto
  437.         if (!preg_match('/^(dr|dra|dr\.|dra\.)\s+/i'$formatted)) {
  438.             $formatted 'Dr. ' $formatted;
  439.         }
  440.         
  441.         return $formatted;
  442.     }
  443.     /**
  444.      * @Route("/cancel/token/{token}", name="appointment_cancel_token_no", methods={"GET", "POST"})
  445.     */
  446.     public function cancelByToken(
  447.         string $token,
  448.         AppointmentRepository $appointmentRepo,
  449.         AppointmentNotifier $notifier,
  450.         HorarioRepository $horario,
  451.         Request $request
  452.     ): Response {
  453.         $appointment $appointmentRepo->findOneBy(['cancelToken' => $token]);
  454.         
  455.         // Validaciones centralizadas
  456.         $validationResult $this->validateTokenAccess($appointment'cancelada');
  457.         if ($validationResult) {
  458.             return $validationResult;
  459.         }
  460.         // Si es POST (confirmación), procesar cancelación
  461.         if ($request->isMethod('POST')) {
  462.             return $this->processCancellation($appointment$notifier$horario);
  463.         }
  464.         // Si es GET, mostrar página de confirmación
  465.         return $this->render('admin/appointment/cancel_confirm.html.twig', [
  466.             'appointment' => $appointment,
  467.             'token' => $token
  468.         ]);
  469.     }
  470.     /**
  471.      * @Route("/reprogram/token/{token}", name="appointment_reprogram_token_no", methods={"GET", "POST"})
  472.      */
  473.     public function reprogramByToken(
  474.         string $token,
  475.         AppointmentRepository $appointmentRepo,
  476.         Request $request,
  477.         HorarioRepository $horario,
  478.         AppointmentNotifier $notifier
  479.     ): Response {
  480.         $appointment $appointmentRepo->findOneBy(['reprogramToken' => $token]);
  481.         
  482.         // Validaciones centralizadas
  483.         $validationResult $this->validateTokenAccess($appointment'reprogramada');
  484.         if ($validationResult) {
  485.             return $validationResult;
  486.         }
  487.         // Si es POST, procesar reprogramación
  488.         if ($request->isMethod('POST')) {
  489.             return $this->processReprogramming($appointment$request$horario$notifier);
  490.         }
  491.         // Si es GET, mostrar formulario de reprogramación
  492.         return $this->render('admin/appointment/reprogram_form.html.twig', [
  493.             'appointment' => $appointment,
  494.             'token' => $token
  495.         ]);
  496.     }
  497.     private function validateTokenAccess(?Appointment $appointmentstring $action): ?Response
  498.     {
  499.         // Token no válido o ya usado
  500.         if (!$appointment) {
  501.             return $this->render('admin/appointment/token_invalid.html.twig', [
  502.                 'action' => $action
  503.             ]);
  504.         }
  505.         // Ya procesada anteriormente
  506.         if (str_starts_with($appointment->getStatus(), $action)) {
  507.             return $this->render('admin/appointment/already_processed.html.twig', [
  508.                 'appointment' => $appointment,
  509.                 'action' => $action
  510.             ]);
  511.         }
  512.         // Cita ya cancelada (para evitar conflictos)
  513.         if (str_starts_with($appointment->getStatus(), 'cancelada') && $action !== 'cancelada') {
  514.             return $this->render('admin/appointment/already_cancelled.html.twig', [
  515.                 'appointment' => $appointment
  516.             ]);
  517.         }
  518.         return null// Todo válido
  519.     }
  520.     private function processCancellation(Appointment $appointmentAppointmentNotifier $notifierHorarioRepository $horarioRepo): Response
  521.     {
  522.         try {
  523.             $this->em->beginTransaction();
  524.             // Marcar como cancelada por paciente
  525.             $appointment->setStatus('cancelada_paciente');
  526.             
  527.             // Liberar horario
  528.             $horario $horarioRepo->find($appointment->getHorario()->getId());
  529.             $horario->setEstado('disponible');
  530.             $appointment->setCancelToken(null); // Invalidar token
  531.             
  532.             $this->em->flush();
  533.             $this->em->commit();
  534.             // Notificar cancelación
  535.             $language $request->getSession()->get('_locale''es'); 
  536.             $notifier->sendAppointmentNotification($appointment'cancel'$language);
  537.             // Registrar en logs
  538.             $this->addFlash('success''Cita cancelada exitosamente.');
  539.             return $this->render('admin/appointment/cancel_success.html.twig', [
  540.                 'appointment' => $appointment
  541.             ]);
  542.         } catch (\Exception $e) {
  543.             $this->em->rollback();
  544.             
  545.             $this->addFlash('error''Error al cancelar la cita. Por favor, intente nuevamente.');
  546.             
  547.             return $this->render('admin/appointment/cancel_confirm.html.twig', [
  548.                 'appointment' => $appointment,
  549.                 'token' => $appointment->getCancelToken(),
  550.                 'error' => true
  551.             ]);
  552.         }
  553.     }
  554.     private function processReprogramming(
  555.     Appointment $appointment
  556.     Request $request,
  557.     HorarioRepository $horarioRepo,
  558.     AppointmentNotifier $notifier
  559.     ): Response
  560.     {
  561.         try {
  562.             $newDate $request->request->get('new_date');
  563.             $newTime $request->request->get('new_time');
  564.             // Validar datos del formulario
  565.             if (!$newDate || !$newTime) {
  566.                 throw new \InvalidArgumentException('Fecha y hora son requeridos');
  567.             }
  568.             // Validar que la fecha no sea en el pasado
  569.             $newDateTime = new \DateTime($newDate ' ' $newTime);
  570.             $now = new \DateTime();
  571.             if ($newDateTime $now) {
  572.                 throw new \InvalidArgumentException('No puede reprogramar para una fecha/hora en el pasado');
  573.             }
  574.             $this->em->beginTransaction();
  575.             // Buscar horario disponible para la nueva fecha y hora
  576.             $nuevoHorario $horarioRepo->findAvailableByDateTime($newDate$newTime);
  577.             
  578.             if (!$nuevoHorario) {
  579.                 throw new \InvalidArgumentException('El horario seleccionado no está disponible');
  580.             }
  581.             // Validar que no sea el mismo horario actual
  582.             $horarioActual $appointment->getHorario();
  583.             if ($nuevoHorario->getId() === $horarioActual->getId()) {
  584.                 throw new \InvalidArgumentException('No puede reprogramar al mismo horario actual');
  585.             }
  586.             // Liberar el horario actual (marcar como disponible)
  587.             $horarioActual->setEstado('disponible');
  588.             
  589.             // Ocupar el nuevo horario
  590.             $nuevoHorario->setEstado('ocupado');
  591.             
  592.             // Actualizar la cita con el nuevo horario
  593.             $appointment->setHorario($nuevoHorario);
  594.             $appointment->setStatus('reprogramada');
  595.             $appointment->setReprogramToken(null); // Invalidar token
  596.             $this->em->flush();
  597.             $this->em->commit();
  598.             // Notificar reprogramación
  599.             $language $request->getSession()->get('_locale''es'); 
  600.             $notifier->sendAppointmentNotification($appointment'reschedule'$language);
  601.             $this->addFlash('success''Cita reprogramada exitosamente.');
  602.             return $this->render('admin/appointment/reprogram_success.html.twig', [
  603.                 'appointment' => $appointment,
  604.                 'newDate' => $newDateTime,
  605.                 'nuevoHorario' => $nuevoHorario
  606.             ]);
  607.         } catch (\Exception $e) {
  608.             if ($this->em->getConnection()->isTransactionActive()) {
  609.                 $this->em->rollback();
  610.             }
  611.             
  612.             $this->addFlash('error''Error al reprogramar la cita: ' $e->getMessage());
  613.             
  614.             return $this->render('admin/appointment/reprogram_form.html.twig', [
  615.                 'appointment' => $appointment,
  616.                 'token' => $appointment->getReprogramToken(),
  617.                 'error' => true,
  618.                 'error_message' => $e->getMessage()
  619.             ]);
  620.         }
  621.     }
  622.     /**
  623.      * @Route("/{id}/status", name="admin_appointment_status", methods={"POST"})
  624.      */
  625.     public function changeStatus(Appointment $appointmentRequest $requestEntityManagerInterface $em): JsonResponse 
  626.     {
  627.         $newStatus $request->request->get('status');
  628.         $reason $request->request->get('reason''');
  629.         // Validación más robusta para el estado
  630.         if ($newStatus === null || $newStatus === '') {
  631.             return new JsonResponse([
  632.                 'success' => false,
  633.                 'message' => 'El estado no puede estar vacío'
  634.             ], 400);
  635.         }
  636.         // Convertir a string y limpiar
  637.         $newStatus = (string) trim($newStatus);
  638.         // Validar estados permitidos
  639.         $allowedStatuses = ['confirmada''completada''cancelada_paciente''cancelada_clinica''ausente'];
  640.         if (!in_array($newStatus$allowedStatuses)) {
  641.             return new JsonResponse([
  642.                 'success' => false,
  643.                 'message' => 'Estado no válido: ' $newStatus
  644.             ], 400);
  645.         }
  646.         try {
  647.             // Cambiar estado de la cita
  648.             $appointment->setStatus($newStatus);
  649.             
  650.             $currentNotes $appointment->getNotes() ?? '';
  651.             $timestamp = (new \DateTime())->format('d/m/Y H:i');
  652.             
  653.             // Manejar observaciones según el tipo de estado
  654.             if (!empty($reason) && $reason !== 'Sin observaciones') {
  655.                 $statusLabels = [
  656.                     'completada' => 'COMPLETADA',
  657.                     'ausente' => 'AUSENTE'
  658.                     'cancelada_paciente' => 'CANCELACIÓN PACIENTE',
  659.                     'cancelada_clinica' => 'CANCELACIÓN CLÍNICA'
  660.                 ];
  661.                 
  662.                 $statusLabel $statusLabels[$newStatus] ?? strtoupper($newStatus);
  663.                 
  664.                 if (!empty($currentNotes)) {
  665.                     $newNotes $currentNotes "\n\n--- {$statusLabel} ({$timestamp}) ---\n" $reason;
  666.                 } else {
  667.                     $newNotes "--- {$statusLabel} ({$timestamp}) ---\n" $reason;
  668.                 }
  669.                 
  670.                 $appointment->setNotes($newNotes);
  671.             }
  672.             // Si es cancelada, liberar horario
  673.             if (str_starts_with($newStatus'cancelada')) {
  674.                 $horario $appointment->getHorario();
  675.                 if ($horario) {
  676.                     $horario->setEstado('disponible');
  677.                 }
  678.             }
  679.             $em->flush();
  680.             return new JsonResponse([
  681.                 'success' => true,
  682.                 'status' => $newStatus,
  683.                 'message' => 'Estado actualizado correctamente'
  684.             ]);
  685.         } catch (\Exception $e) {
  686.             return new JsonResponse([
  687.                 'success' => false,
  688.                 'message' => 'Error al actualizar el estado: ' $e->getMessage()
  689.             ], 500);
  690.         }
  691.     }
  692.     /**
  693.      * @Route("/{id}", name="admin_appointment_show", methods={"GET"})
  694.      */
  695.     public function show(Appointment $appointment): Response
  696.     {
  697.         return $this->render('admin/appointment/show.html.twig', [
  698.             'appointment' => $appointment,
  699.             'seccion' => 'appointments',
  700.             'titulo_pagina' => 'Mostrar Cita'
  701.         ]);
  702.     }
  703.     
  704.     // /**
  705.     //  * @Route("/{id}/reprogramar", name="admin_appointment_reschedule", methods={"GET", "POST"})
  706.     // */
  707.     // public function reschedule(Request $request, Appointment $appointment, EntityManagerInterface $entityManager, HorarioRepository $horarioRepo) : Response
  708.     // {
  709.     //     $originalHorario = $appointment->getHorario();
  710.     //     $originalDate = $originalHorario->getFecha();
  711.     //     $originalTime = $originalHorario->getHora();
  712.     //     $provider = $this->getUser();
  713.     //     // SOLUCIÓN: Eliminar las opciones no definidas
  714.     //     $form = $this->createForm(RescheduleAppointmentType::class, $appointment, [
  715.     //         'provider' => $provider,
  716.     //         'is_reschedule' => true,
  717.     //         'context' => 'admin',
  718.     //     ]);
  719.     //     $form->handleRequest($request);
  720.     //     if ($form->isSubmitted() && $form->isValid()) {
  721.     //         try {
  722.     //             // Liberar el horario anterior si existe
  723.     //             if ($originalHorario && $originalHorario->getId() !== $appointment->getHorario()->getId()) {
  724.     //                 $originalHorario->setEstado('disponible');
  725.     //                 $entityManager->persist($originalHorario);
  726.     //             }
  727.     //             // Ocupar el nuevo horario
  728.     //             $nuevoHorario = $appointment->getHorario();
  729.     //             $nuevoHorario->setEstado('ocupado');
  730.     //             // Actualizar estado y datos
  731.     //             $appointment->setStatus('reprogramada');
  732.     //             $appointment->setProvider($this->getUser());
  733.     //             $entityManager->flush();
  734.                 
  735.     //             $language = $request->getSession()->get('_locale', 'es'); 
  736.     //             if ($this->appointmentNotifier) {
  737.     //                 $this->appointmentNotifier->sendAppointmentNotification($appointment, 'reschedule', $language);
  738.     //             }
  739.     //             $this->addFlash('success', 'Cita reprogramada correctamente.');
  740.     //             return $this->redirectToRoute('admin_appointment', [], Response::HTTP_SEE_OTHER);
  741.     //         } catch (\Exception $e) {
  742.     //             $this->addFlash('error', 'Error al reprogramar la cita: ' . $e->getMessage());
  743.     //         }
  744.     //     }
  745.     //     return $this->renderForm('admin/appointment/reschedule.html.twig', [
  746.     //         'appointment' => $appointment,
  747.     //         'form' => $form,
  748.     //         'original_date' => $originalDate,
  749.     //         'original_time' => $originalTime,
  750.     //         'modo' => 'reschedule',
  751.     //         'seccion' => 'appointments',
  752.     //         'titulo_pagina' => 'Reprogramar Cita'
  753.     //     ]);
  754.     // }
  755.     
  756.     /**
  757.      * @Route("/{id}/reprogramar", name="admin_appointment_reschedule", methods={"GET", "POST"})
  758.      */
  759.     public function reschedule(Request $requestAppointment $appointmentEntityManagerInterface $entityManagerHorarioRepository $horarioRepo): Response
  760.     {
  761.         $originalHorario $appointment->getHorario();
  762.         $originalDate = clone $originalHorario->getFecha();
  763.         $originalTime = clone $originalHorario->getHora();
  764.         
  765.         $provider $this->getUser();
  766.     
  767.         // Crear formulario
  768.         $form $this->createForm(RescheduleAppointmentType::class, $appointment, [
  769.             'provider' => $provider,
  770.             'is_reschedule' => true,
  771.             'context' => 'admin',
  772.         ]);
  773.     
  774.         $form->handleRequest($request);
  775.     
  776.        if ($form->isSubmitted() && $form->isValid()) {
  777.             try {
  778.                 // Obtener datos del formulario
  779.                 $horarioSeleccionado $appointment->getHorario();
  780.                 $notas $form->get('notes')->getData() ?? '';
  781.                 
  782.                 // NO uses $nuevaFecha = $form->get('fecha')->getData();
  783.                 // La fecha CORRECTA está en: $horarioSeleccionado->getFecha()
  784.                 // La hora CORRECTA está en: $horarioSeleccionado->getHora()
  785.                 
  786.                 if (!$horarioSeleccionado) {
  787.                     $this->addFlash('error''Debe seleccionar un nuevo horario.');
  788.                     return $this->redirectToRoute('admin_appointment_reschedule', [
  789.                         'id' => $appointment->getId()
  790.                     ]);
  791.                 }
  792.         
  793.                 // 1. VERIFICAR SI ES EL MISMO HORARIO (comparando IDs)
  794.                 if ($horarioSeleccionado->getId() === $originalHorario->getId()) {
  795.                     $this->addFlash('warning''Seleccionó el mismo horario actual.');
  796.                     return $this->redirectToRoute('admin_appointment_reschedule', ['id' => $appointment->getId()]);
  797.                 }
  798.         
  799.                 // 2. VERIFICAR QUE EL HORARIO SELECCIONADO ESTÉ DISPONIBLE
  800.                 if ($horarioSeleccionado->getEstado() !== 'disponible') {
  801.                     $this->addFlash('error''El horario seleccionado ya no está disponible. Por favor, seleccione otro.');
  802.                     return $this->redirectToRoute('admin_appointment_reschedule', ['id' => $appointment->getId()]);
  803.                 }
  804.         
  805.                 // 3. OCUPAR EL NUEVO HORARIO
  806.                 $horarioSeleccionado->setEstado('ocupado');
  807.                 $entityManager->persist($horarioSeleccionado);
  808.         
  809.                 // 4. LIBERAR EL HORARIO ANTERIOR
  810.                 $originalHorario->setEstado('disponible');
  811.                 $entityManager->persist($originalHorario);
  812.         
  813.                 // 5. ACTUALIZAR LA CITA CON EL NUEVO HORARIO (¡con su fecha y hora!)
  814.                 $appointment->setHorario($horarioSeleccionado); // ← Esto actualiza AMBOS: fecha y hora
  815.                 $appointment->setStatus('reprogramada');
  816.                 $appointment->setUpdatedAt(new \DateTimeImmutable());
  817.         
  818.                 // 6. AGREGAR NOTAS DE REPROGRAMACIÓN
  819.                 $notasReprogramacion sprintf(
  820.                     "\n=== REPROGRAMACIÓN ===\n" .
  821.                     "Fecha cambio: %s\n" .
  822.                     "Original: %s %s\n" .
  823.                     "Nuevo: %s %s\n" .  // ← Usa la fecha del horario seleccionado
  824.                     "Motivo: %s\n" .
  825.                     "========================\n",
  826.                     (new \DateTime())->format('d/m/Y H:i'),
  827.                     $originalDate->format('d/m/Y'),
  828.                     $originalTime->format('H:i'),
  829.                     $horarioSeleccionado->getFecha()->format('d/m/Y'),  // ← AQUÍ
  830.                     $horarioSeleccionado->getHora()->format('H:i'),      // ← AQUÍ
  831.                     $notas ?: 'Sin motivo especificado'
  832.                 );
  833.         
  834.                 $notasActuales $appointment->getNotes() ?? '';
  835.                 $appointment->setNotes($notasReprogramacion $notasActuales);
  836.         
  837.                 // 7. GUARDAR
  838.                 $entityManager->flush();
  839.         
  840.                 // 8. DEBUG: Verificar que se guardó correctamente
  841.                 error_log("Reprogramación exitosa:");
  842.                 error_log("- Cita ID: " $appointment->getId());
  843.                 error_log("- Nuevo Horario ID: " $horarioSeleccionado->getId());
  844.                 error_log("- Nueva Fecha: " $horarioSeleccionado->getFecha()->format('Y-m-d'));
  845.                 error_log("- Nueva Hora: " $horarioSeleccionado->getHora()->format('H:i'));
  846.                 error_log("- Estado: " $appointment->getStatus());
  847.         
  848.                 // 9. ENVIAR NOTIFICACIÓN
  849.                 $language $request->getSession()->get('_locale''es'); 
  850.                 if ($this->appointmentNotifier) {
  851.                     $this->appointmentNotifier->sendAppointmentNotification($appointment'reschedule'$language);
  852.                 }
  853.         
  854.                 $this->addFlash('success''Cita reprogramada correctamente.');
  855.                 return $this->redirectToRoute('admin_appointment', [], Response::HTTP_SEE_OTHER);
  856.         
  857.             } catch (\Exception $e) {
  858.                 error_log('ERROR REPROGRAMACIÓN: ' $e->getMessage());
  859.                 error_log('Trace: ' $e->getTraceAsString());
  860.                 
  861.                 $this->addFlash('error''Error al reprogramar la cita: ' $e->getMessage());
  862.             }
  863.         }
  864.     
  865.         return $this->render('admin/appointment/reschedule.html.twig', [
  866.             'appointment' => $appointment,
  867.             'form' => $form->createView(),
  868.             'original_date' => $originalDate,
  869.             'original_time' => $originalTime,
  870.             'modo' => 'reschedule',
  871.             'seccion' => 'appointments',
  872.             'titulo_pagina' => 'Reprogramar Cita'
  873.         ]);
  874.     }
  875.     /**
  876.      * @Route("/{id}/edit", name="admin_appointment_edit", methods={"GET", "POST"})
  877.      */
  878.     public function edit(Request $requestAppointment $appointmentEntityManagerInterface $entityManager,  HorarioRepository $horarioRepo): Response
  879.     {
  880.         $form $this->createForm(AppointmentType::class, $appointment, [
  881.             'provider' => null,
  882.             'is_edit' => true,
  883.             'context' => 'admin'
  884.         ]);
  885.         $form->handleRequest($request);
  886.         if ($form->isSubmitted() && $form->isValid()) {
  887.             // Asignar automáticamente el admin como proveedor
  888.             $appointment->setProvider($this->getUser());
  889.             // Asignar automáticamente el admin como proveedor
  890.             $appointment->setStatus('confirmada');
  891.             $appointment->setPaymentMethod('imaging_pro');
  892.             
  893.             $horario $horarioRepo->find($appointment->getHorario()->getId());
  894.             $horario->setEstado('ocupado');
  895.             $appointment->setUpdatedAt(new \DateTimeImmutable());
  896.             $entityManager->flush();
  897.  
  898.             // Notificar
  899.             $language $request->getSession()->get('_locale''es'); 
  900.             $this->appointmentNotifier->sendAppointmentNotification($appointment'reschedule'$language);
  901.             $this->addFlash('info''Cita actualizado correctamente.');
  902.             return $this->redirectToRoute('admin_appointment', [], Response::HTTP_SEE_OTHER);
  903.         }
  904.         return $this->renderForm('admin/appointment/form.html.twig', [
  905.             'appointment' => $appointment,
  906.             'form' => $form,
  907.             'modo' => 'edit',
  908.             'seccion' => 'appointments',
  909.             'titulo_pagina' => 'Editar Cita'
  910.         ]);
  911.     }
  912.     /**
  913.      * @Route("/{id}", name="admin_appointment_delete", methods={"POST"})
  914.      */
  915.     public function delete(Request $request$idEntityManagerInterface $entityManagerHorarioRepository $horarioRepo): Response
  916.     {
  917.         // Buscar la cita manualmente en lugar de usar ParamConverter
  918.         $appointment $entityManager->getRepository(Appointment::class)->find($id);
  919.         
  920.         // Verificar si la cita existe
  921.         if (!$appointment) {
  922.             if ($request->isXmlHttpRequest()) {
  923.                 return new JsonResponse([
  924.                     'success' => false,
  925.                     'message' => 'La cita no existe o ya fue eliminada'
  926.                 ], 404);
  927.             }
  928.             
  929.             $this->addFlash('warning''La cita no existe o ya fue eliminada.');
  930.             return $this->redirectToRoute('admin_appointment');
  931.         }
  932.     
  933.         // Verificar token CSRF
  934.         if (!$this->isCsrfTokenValid('delete'.$appointment->getId(), $request->request->get('_token'))) {
  935.             if ($request->isXmlHttpRequest()) {
  936.                 return new JsonResponse([
  937.                     'success' => false,
  938.                     'message' => 'Token CSRF inválido'
  939.                 ], 400);
  940.             }
  941.             
  942.             $this->addFlash('error''Token CSRF inválido');
  943.             return $this->redirectToRoute('admin_appointment');
  944.         }
  945.     
  946.         try {
  947.             $appointmentId $appointment->getId();
  948.             $entityManager->remove($appointment);
  949.             $horario $horarioRepo->find($appointment->getHorario()->getId());
  950.             $horario->setEstado('disponible');
  951.             $entityManager->flush();
  952.     
  953.             if ($request->isXmlHttpRequest()) {
  954.                 return new JsonResponse([
  955.                     'success' => true,
  956.                     'message' => 'Cita eliminada correctamente',
  957.                     'appointment_id' => $appointmentId
  958.                 ]);
  959.             }
  960.     
  961.             $this->addFlash('success''Cita eliminada correctamente');
  962.             
  963.         } catch (\Exception $e) {
  964.             // En caso de error durante la eliminación
  965.             if ($request->isXmlHttpRequest()) {
  966.                 return new JsonResponse([
  967.                     'success' => false,
  968.                     'message' => 'Error al eliminar la cita: ' $e->getMessage()
  969.                 ], 500);
  970.             }
  971.             
  972.             $this->addFlash('error''Error al eliminar la cita: ' $e->getMessage());
  973.         }
  974.     
  975.         return $this->redirectToRoute('admin_appointment');
  976.     }
  977. }