src/Controller/EventsController.php line 1237

  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Events;
  4. use App\Entity\Attendance;
  5. use App\Form\EventsFormType;
  6. use App\Repository\AttendanceRepository;
  7. use App\Repository\RegistrationsRepository;
  8. use App\Repository\HomeSliderRepository;
  9. use App\Repository\RoleRepository;
  10. use App\Repository\EventsRepository;
  11. use Symfony\Component\HttpFoundation\Request;
  12. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  13. use Symfony\Component\HttpFoundation\JsonResponse;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  16. use Symfony\Component\Routing\Annotation\Route;
  17. use Symfony\Contracts\Translation\TranslatorInterface;
  18. use Doctrine\ORM\EntityManagerInterface;
  19. use App\Controller\RolesController;
  20. use App\Controller\NotificationsController;
  21. use App\Repository\ParamsRepository;
  22. use Symfony\Component\HttpFoundation\RequestStack;
  23. use Symfony\Component\Messenger\MessageBusInterface;
  24. use App\Message\SendEmailMessage;
  25. use Knp\Component\Pager\PaginatorInterface;
  26. use App\Repository\TaxonomyValueRepository;
  27. use Dompdf\Dompdf;
  28. use Dompdf\Options;
  29. use App\Entity\EventRequest;
  30. use Symfony\Component\HttpKernel\KernelInterface;
  31. use App\Entity\Registrations;
  32. use App\Controller\UtilsController;
  33. use App\Enum\EventStatus;
  34. use App\Repository\EventRequestRepository;
  35. use App\Entity\RegistrationQualification;
  36. use App\Entity\User;
  37. use App\Repository\RegistrationQualificationRepository;
  38. use App\Repository\RegistrationCertificateRepository;
  39. use App\Repository\SurveyCampaignRepository;
  40. use App\Repository\UserPenaltyRepository;
  41. use App\Services\CertificateGenerationService;
  42. use App\Services\EventQualificationService;
  43. use App\Services\EventInscriptionEndDateAdjustmentService;
  44. use App\Services\QualificationCsvService;
  45. use App\Services\RoleAccessService;
  46. use App\Services\SurveyLifecycleService;
  47. use App\Services\TeacherHourWarningService;
  48. use Symfony\Component\Form\FormError;
  49. use Symfony\Component\Form\FormInterface;
  50. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  51. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  52. class EventsController extends AbstractController
  53. {
  54.     private const QUALIFICATION_TYPE_ROUTE_REQUIREMENT 'ASISTENCIAS|CUESTIONARIO|PRACTICAS|CALIFICACION_FINAL';
  55.     private const FINAL_QUALIFICATIONS_DOWNLOAD_BLOCKED_MESSAGE 'La descarga estará disponible cuando todas las calificaciones se hayan cumplimentado.';
  56.     private $RolesController;
  57.     private $NotificationsController;
  58.     private MessageBusInterface $bus;
  59.     public function __construct(private EventsRepository $eventsRepository
  60.                                 private TranslatorInterface $translator ,
  61.                                 private RoleRepository $roleRepository
  62.                                 private EntityManagerInterface $entityManager
  63.                                 private AttendanceRepository $attendanceRepository,
  64.                                 private ParamsRepository $paramsRepository,
  65.                                 private RegistrationQualificationRepository $registrationQualificationRepository,
  66.                                 private RegistrationCertificateRepository $registrationCertificateRepository,
  67.                                 private SurveyCampaignRepository $surveyCampaignRepository,
  68.                                 private UserPenaltyRepository $userPenaltyRepository,
  69.                                 private CertificateGenerationService $certificateGenerationService,
  70.                                 private EventQualificationService $eventQualificationService,
  71.                                 private EventInscriptionEndDateAdjustmentService $eventInscriptionEndDateAdjustmentService,
  72.                                 private QualificationCsvService $qualificationCsvService,
  73.                                 private RoleAccessService $roleAccessService,
  74.                                 private SurveyLifecycleService $surveyLifecycleService,
  75.                                 private TeacherHourWarningService $teacherHourWarningService,
  76.                                 RolesController $rolesController,
  77.                                 NotificationsController $notificationsController,
  78.                                 private RequestStack $requestStack,
  79.                                 MessageBusInterface $bus,
  80.                                 private UtilsController $utilsController,
  81.                                 )
  82.     {
  83.         $this->RolesController $rolesController;
  84.         $this->NotificationsController $notificationsController;
  85.         $this->bus $bus;
  86.     }
  87.   
  88.     
  89.     #[Route('/admin/events'name'app_events')]
  90.     public function index(): Response
  91.     {
  92.         return $this->render('admin/events/events.html.twig', [
  93.             'eventsDataUrl' => $this->generateUrl('app_admin_events_data'),
  94.             'eventsExportUrl' => $this->generateUrl('app_admin_events_export'),
  95.         ]);
  96.     }
  97.     #[Route('/admin/events/data'name'app_admin_events_data'methods: ['GET'])]
  98.     public function eventsData(Request $request): JsonResponse
  99.     {
  100.         $query $request->query->all();
  101.         $draw max(0, (int) ($query['draw'] ?? 0));
  102.         $offset max(0, (int) ($query['start'] ?? 0));
  103.         $limit max(1min(100, (int) ($query['length'] ?? 10)));
  104.         $search trim((string) (($query['search']['value'] ?? '') ?: ''));
  105.         [$orderColumn$orderDirection] = $this->resolveEventDataTableOrdering($request);
  106.         $eventIds $this->getVisibleEventIds();
  107.         $recordsTotal $this->eventsRepository->countVisibleForIds($eventIds);
  108.         $recordsFiltered $search === ''
  109.             $recordsTotal
  110.             $this->eventsRepository->countVisibleForIds($eventIds$search);
  111.         $rows $this->eventsRepository->findVisiblePageForIds(
  112.             $eventIds,
  113.             $search,
  114.             $orderColumn,
  115.             $orderDirection,
  116.             $offset,
  117.             $limit
  118.         );
  119.         $actionVisibility $this->buildEventActionVisibility();
  120.         $data array_map(
  121.             fn (array $row): array => $this->buildEventTableRow($row$actionVisibility),
  122.             $rows
  123.         );
  124.         return new JsonResponse([
  125.             'draw' => $draw,
  126.             'recordsTotal' => $recordsTotal,
  127.             'recordsFiltered' => $recordsFiltered,
  128.             'data' => $data,
  129.         ]);
  130.     }
  131.     #[Route('/admin/events/teacher-hour-warnings'name'app_admin_event_teacher_hour_warnings'methods: ['GET'])]
  132.     public function teacherHourWarnings(Request $request): JsonResponse
  133.     {
  134.         $eventId max(0, (int) $request->query->get('eventId'0));
  135.         if ($eventId 0) {
  136.             $event $this->eventsRepository->find($eventId);
  137.             if (!$event instanceof Events) {
  138.                 return new JsonResponse(['message' => 'Evento no encontrado.'], Response::HTTP_NOT_FOUND);
  139.             }
  140.             if (!$this->RolesController->canEditEvent($event)) {
  141.                 return new JsonResponse(['message' => 'No tienes permisos para editar este evento.'], Response::HTTP_FORBIDDEN);
  142.             }
  143.         } elseif (!$this->RolesController->hasCapability('CREATE_EVENTS')) {
  144.             return new JsonResponse(['message' => 'No tienes permisos para crear eventos.'], Response::HTTP_FORBIDDEN);
  145.         }
  146.         $teacherIds array_values(array_filter(array_map(
  147.             static fn (mixed $teacherId): int => (int) $teacherId,
  148.             (array) ($request->query->all()['teacherIds'] ?? [])
  149.         )));
  150.         $year $this->resolveTeacherWarningYear($request->query->get('year'));
  151.         $hours $request->query->get('hours');
  152.         $eventHours is_numeric((string) $hours) ? max(0, (int) $hours) : 0;
  153.         if ($teacherIds === [] || $year === null) {
  154.             return new JsonResponse(['warnings' => []]);
  155.         }
  156.         return new JsonResponse([
  157.             'warnings' => $this->teacherHourWarningService->buildWarnings(
  158.                 $teacherIds,
  159.                 $year,
  160.                 $eventId $eventId null,
  161.                 $eventHours
  162.             ),
  163.         ]);
  164.     }
  165.     #[Route('/admin/events/export'name'app_admin_events_export'methods: ['GET'])]
  166.     public function eventsExport(Request $request): Response
  167.     {
  168.         if (!$this->RolesController->hasCapability('VIEW_EVENTS')) {
  169.             $this->addFlash('error''No tienes permisos para exportar cursos');
  170.             return $this->redirectToRoute('app_events');
  171.         }
  172.         $search trim((string) $request->query->get('search'''));
  173.         [$orderColumn$orderDirection] = $this->resolveEventExportOrdering($request);
  174.         $eventIds $this->getVisibleEventIds();
  175.         $rows $this->eventsRepository->findVisibleExportRowsForIds(
  176.             $eventIds,
  177.             $search,
  178.             $orderColumn,
  179.             $orderDirection
  180.         );
  181.         $spreadsheet = new Spreadsheet();
  182.         $sheet $spreadsheet->getActiveSheet();
  183.         $sheet->fromArray([
  184.             'Publicado',
  185.             'Estado',
  186.             'Código',
  187.             'Nombre',
  188.             'Inicio',
  189.             'Fin',
  190.             'Solicitudes',
  191.             'Inscripciones',
  192.             'Ocupación',
  193.         ], null'A1');
  194.         $rowNumber 2;
  195.         foreach ($rows as $row) {
  196.             $registrationMetrics $this->buildEventRegistrationMetrics($row);
  197.             $statusValue $row['status'] ?? '';
  198.             if ($statusValue instanceof EventStatus) {
  199.                 $statusValue $statusValue->value;
  200.             }
  201.             $sheet->fromArray([
  202.                 (bool) ($row['published'] ?? false) ? 'Sí' 'No',
  203.                 $this->formatEventStatusLabel((string) $statusValue),
  204.                 (string) ($row['extCode'] ?? ''),
  205.                 (string) ($row['name'] ?? ''),
  206.                 $this->formatEventDate($row['startDate'] ?? null),
  207.                 $this->formatEventDate($row['endDate'] ?? null),
  208.                 max(0, (int) ($row['requestCount'] ?? 0)),
  209.                 $registrationMetrics['registrationSummary'],
  210.                 $registrationMetrics['registrationPercentLabel'],
  211.             ], null'A'.$rowNumber);
  212.             $rowNumber++;
  213.         }
  214.         foreach ($sheet->getColumnIterator() as $column) {
  215.             $sheet->getColumnDimension($column->getColumnIndex())->setAutoSize(true);
  216.         }
  217.         $sheet->getStyle('A1:I1')->getFont()->setBold(true);
  218.         $tmpFile tempnam(sys_get_temp_dir(), 'events_export_');
  219.         if ($tmpFile === false) {
  220.             throw new \RuntimeException('No se pudo crear el fichero temporal para la exportación.');
  221.         }
  222.         $writer = new Xlsx($spreadsheet);
  223.         $writer->save($tmpFile);
  224.         $fileName = (new \DateTimeImmutable())->format('Ymd').'-cursos.xlsx';
  225.         $response = new BinaryFileResponse($tmpFile200, [
  226.             'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  227.         ]);
  228.         $response->setContentDisposition('attachment'$fileName);
  229.         $response->deleteFileAfterSend(true);
  230.         return $response;
  231.     }
  232.     #[Route(path'/admin/events/new'name'app_admin_new_event')]
  233.     public function newEvent(Request $request)
  234.     {
  235.         if (!$this->RolesController->hasCapability('CREATE_EVENTS')) {
  236.             $this->addFlash('error''No tienes permisos para crear eventos');
  237.             return $this->redirectToRoute('app_events');
  238.         }
  239.         
  240.         $form $this->createForm(EventsFormType::class);
  241.         $form->handleRequest($request);
  242.         if ($form->isSubmitted() && $form->isValid()) {
  243.             // /** @var Events $Event */
  244.         
  245.             $event $form->getData();
  246.             $imageFile $form->get('imageFile')->getData();
  247.             if ($imageFile) {
  248.                 $newFilename uniqid().'.'.$imageFile->guessExtension();
  249.                 $imageFile->move($this->getParameter('kernel.project_dir').'/public/images'$newFilename);
  250.                 $event->setImage($newFilename);
  251.             }
  252.             if ($request->request->get('remove_image')) {
  253.                 $event->setImage(null);
  254.             }
  255.             if($event->getNumberCheckings() == null){ //Si el número necesario de fichajes está vacío lo calculamos 
  256.                 if($event->getShift() == "" || $event->getShift() == null || $event->getShift() == "1") {
  257.                     $numberCheckings $event->getTotalDays() * 1//Si no está especificado el tipo de jornada o si la jornada es contínua el número de fichajes es 1 por día.
  258.                 }else{
  259.                     $numberCheckings $event->getTotalDays() * 2
  260.                 }
  261.                 $event->setNumberCheckings($numberCheckings);
  262.             }
  263.             
  264.             $event->setCreated((new \DateTime()));
  265.             $event->setUpdated((new \DateTime()));
  266.             $event->setStatus(EventStatus::ABIERTO);
  267.             $this->applyAutomaticEventOwnership($event);
  268.             if (!$this->ensureEventCenterIsResolved($event$form)) {
  269.                 return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  270.             }
  271.             if (!$this->ensureEventAssignmentsAreValid($event$form)) {
  272.                 return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  273.             }
  274.             $this->entityManager->persist($event);
  275.             $this->entityManager->flush();
  276.             $this->addFlash('success''Evento guardado');
  277.             $this->addTeacherHourWarningFlashes($event);
  278.             return $this->redirectToRoute('app_events');
  279.         }
  280.         return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  281.     }
  282.     #[Route(path'/admin/events/view/{id}'name'app_admin_view_event')]
  283.     public function viewEvent(Request $request$id)
  284.     {
  285.         if (!$this->RolesController->hasCapability('VIEW_EVENTS')) {
  286.             $this->addFlash('error''No tienes permisos para ver este evento');
  287.             return $this->redirectToRoute('app_events');
  288.         }
  289.         $event $this->eventsRepository->find($id);
  290.         //Comprobamos que el usuario tiene asociado de alguna manera el evento y puede acceder al él.
  291.         
  292.         $allow $this->RolesController->checkAccessToEvent($event);
  293.         if($allow == false){
  294.             $this->addFlash('error''No tienes permisos para ver este evento');
  295.             return $this->redirectToRoute('app_events');
  296.         }
  297.         $eventRegistrations $event->getActiveRegistrations();
  298.         $numRegistrations $eventRegistrations->count();
  299.         $eventAttendances $this->attendanceRepository->findBy(['eventId' => $id]);
  300.         $eventManagerUsers $event->getManagerUsers();
  301.         $eventCourseUsers $event->getCourseUser();
  302.         $eventRequests $event->getEventRequests();
  303.         $numRequests $eventRequests->count();
  304.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  305.         $finalQualificationType RegistrationQualification::TIPO_CALIFICACION_FINAL;
  306.         $enabledQualificationTypes array_keys(array_filter(
  307.             $qualificationTypesConfig,
  308.             static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false) && !($cfg['final_tab_only'] ?? false)
  309.         ));
  310.         $enabledQualificationTypesForView array_keys(array_filter(
  311.             $qualificationTypesConfig,
  312.             static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false)
  313.         ));
  314.         $existingQualifications $this->registrationQualificationRepository->findByEventAndTypes(
  315.             $event,
  316.             $enabledQualificationTypesForView
  317.         );
  318.         $qualificationsByType = [];
  319.         foreach ($existingQualifications as $qualification) {
  320.             $registration $qualification->getRegistration();
  321.             if ($registration === null) {
  322.                 continue;
  323.             }
  324.             $qualificationsByType[$qualification->getTipoCalificacion()][$registration->getId()] = $qualification;
  325.         }
  326.         $finalGradeTabEnabled = (bool) ($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false);
  327.         $allowedQualificationTabs $enabledQualificationTypes;
  328.         if ($finalGradeTabEnabled) {
  329.             $allowedQualificationTabs[] = $finalQualificationType;
  330.         }
  331.         $activeQualificationTab = (string) $request->query->get('qualification_tab''');
  332.         if (!in_array($activeQualificationTab$allowedQualificationTabstrue)) {
  333.             $activeQualificationTab $enabledQualificationTypes[0] ?? ($finalGradeTabEnabled $finalQualificationType '');
  334.         }
  335.         //Cálculo de cumplimiento global del curso
  336.         $eventPercentCompliance $this->RolesController->getComplianceByEvent($event);
  337.         $qualificationRows $this->eventQualificationService->buildQualificationRows(
  338.             $eventRegistrations,
  339.             $qualificationsByType,
  340.             $enabledQualificationTypes,
  341.             $finalQualificationType,
  342.             (float) ($event->getMinCompliance() ?? 0.0)
  343.         );
  344.         $activePenaltyByRegistration = [];
  345.         $registrationUsers = [];
  346.         foreach ($eventRegistrations as $registration) {
  347.             $user $registration->getUserId();
  348.             if ($user !== null) {
  349.                 $registrationUsers[] = $user;
  350.             }
  351.         }
  352.         $activePenaltiesByUserId $this->userPenaltyRepository->findActiveForUsersAndDate($registrationUsers, new \DateTime('today'));
  353.         foreach ($eventRegistrations as $registration) {
  354.             $registrationId $registration->getId();
  355.             $userId $registration->getUserId()?->getId();
  356.             if ($registrationId === null || $userId === null || !isset($activePenaltiesByUserId[$userId])) {
  357.                 continue;
  358.             }
  359.             $activePenaltyByRegistration[$registrationId] = $activePenaltiesByUserId[$userId];
  360.         }
  361.         $downloadAvailability $this->eventQualificationService->getDownloadAvailability($event);
  362.         $surveyCampaign $this->surveyCampaignRepository->findOneBy(['event' => $event]);
  363.         if ($surveyCampaign instanceof \App\Entity\SurveyCampaign && $this->surveyLifecycleService->refreshCampaignState($surveyCampaign)) {
  364.             $this->entityManager->flush();
  365.         }
  366.         $courseUserHourWarnings $this->buildTeacherHourWarningsForEvent($event);
  367.         $forceStatusInscriptionEndAdjustment $this->eventInscriptionEndDateAdjustmentService->buildAdjustmentContext(
  368.             $event,
  369.             EventStatus::INSCRIPCIONES_CERRADAS
  370.         );
  371.         return $this->render('admin/events/eventview.html.twig', [
  372.             'event' => $event,
  373.             'eventRegistrations' => $eventRegistrations,
  374.             'numRegistrations' => $numRegistrations,
  375.             'Attendances' => $eventAttendances,
  376.             'managerUsers' => $eventManagerUsers,
  377.             'courseUsers' => $eventCourseUsers,
  378.             'permissions' => $this->RolesController->getPermissionsByRol(),
  379.             'compliance' => $eventPercentCompliance,
  380.             'eventRequests' => $eventRequests,
  381.             'numRequests' => $numRequests,
  382.             'minCompliance' => $event->getMinCompliance(),
  383.             'qualificationTypesConfig' => $qualificationTypesConfig,
  384.             'enabledQualificationTypes' => $enabledQualificationTypes,
  385.             'qualificationsByType' => $qualificationsByType,
  386.             'qualificationRows' => $qualificationRows,
  387.             'activeQualificationTab' => $activeQualificationTab,
  388.             'finalQualificationType' => $finalQualificationType,
  389.             'finalGradeTabEnabled' => $finalGradeTabEnabled,
  390.             'penaltyReasonOptions' => $this->eventQualificationService->getPenaltyReasonOptions(),
  391.             'penaltyReasonMetadata' => $this->eventQualificationService->getPenaltyReasonMetadata(),
  392.             'penaltyReasonLabels' => $this->eventQualificationService->getAllPenaltyReasonLabels(),
  393.             'activePenaltyByRegistration' => $activePenaltyByRegistration,
  394.             'downloadAvailability' => $downloadAvailability,
  395.             'surveyCampaign' => $surveyCampaign,
  396.             'surveySendAvailability' => $surveyCampaign instanceof \App\Entity\SurveyCampaign
  397.                 $this->surveyLifecycleService->getSendAvailability($surveyCampaign)
  398.                 : null,
  399.             'courseUserHourWarnings' => $courseUserHourWarnings,
  400.             'forceStatusInscriptionEndAdjustment' => $forceStatusInscriptionEndAdjustment,
  401.             'certificateGenerationStatus' => $this->certificateGenerationService->getEventGenerationStatus($event),
  402.             'activeCertificatesByRegistration' => $this->registrationCertificateRepository->findActiveByEventIndexed($event),
  403.         ]);
  404.     }
  405.     #[Route(
  406.         path'/admin/events/{id}/qualifications/{type}',
  407.         name'app_admin_event_save_qualifications',
  408.         methods: ['POST'],
  409.         requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT]
  410.     )]
  411.     public function saveQualifications(Events $eventstring $typeRequest $request): Response
  412.     {
  413.         if (!$this->RolesController->canEditQualifications($event)) {
  414.             $this->addFlash('error''No tienes permisos para editar este evento');
  415.             return $this->redirectToRoute('app_events');
  416.         }
  417.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  418.         if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {
  419.             $this->addFlash('error''El tipo de calificación no está habilitado para este evento.');
  420.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  421.         }
  422.         if (($qualificationTypesConfig[$type]['editable'] ?? false) === false) {
  423.             $this->addFlash('error''Este tipo de calificación se calcula automáticamente.');
  424.             return $this->redirectToRoute('app_admin_view_event', [
  425.                 'id' => $event->getId(),
  426.                 'qualification_tab' => $type,
  427.             ]);
  428.         }
  429.         $token = (string) $request->request->get('_token');
  430.         if (!$this->isCsrfTokenValid('save_qualifications_'.$event->getId().'_'.$type$token)) {
  431.             $this->addFlash('error''Token CSRF inválido.');
  432.             return $this->redirectToRoute('app_admin_view_event', [
  433.                 'id' => $event->getId(),
  434.                 'qualification_tab' => $type,
  435.             ]);
  436.         }
  437.         $submittedQualifications $request->request->all('qualifications');
  438.         try {
  439.             $result $this->eventQualificationService->saveQualificationsForType($event$type$submittedQualifications$this->getUser());
  440.             $this->addFlash('success''Calificaciones guardadas correctamente.');
  441.             $this->addQualificationWarnings($result['warnings'] ?? []);
  442.         } catch (\InvalidArgumentException $exception) {
  443.             $this->addFlash('error'$exception->getMessage());
  444.         }
  445.         return $this->redirectToRoute('app_admin_view_event', [
  446.             'id' => $event->getId(),
  447.             'main_tab' => 'calificaciones',
  448.             'qualification_tab' => $type,
  449.         ]);
  450.     }
  451.     #[Route(path'/admin/events/{id}/qualifications/asistencias/propose'name'app_admin_event_propose_attendance_qualifications'methods: ['POST'])]
  452.     public function proposeAttendanceQualifications(Events $eventRequest $request): Response
  453.     {
  454.         if (!$this->RolesController->canEditQualifications($event)) {
  455.             $this->addFlash('error''No tienes permisos para editar este evento');
  456.             return $this->redirectToRoute('app_events');
  457.         }
  458.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  459.         if (!($qualificationTypesConfig[RegistrationQualification::TIPO_ASISTENCIAS]['enabled'] ?? false)) {
  460.             $this->addFlash('error''El control de asistencias no está habilitado para este evento.');
  461.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  462.         }
  463.         $token = (string) $request->request->get('_token');
  464.         if (!$this->isCsrfTokenValid('propose_attendance_qualifications_'.$event->getId(), $token)) {
  465.             $this->addFlash('error''Token CSRF inválido.');
  466.             return $this->redirectToRoute('app_admin_view_event', [
  467.                 'id' => $event->getId(),
  468.                 'main_tab' => 'calificaciones',
  469.                 'qualification_tab' => RegistrationQualification::TIPO_ASISTENCIAS,
  470.             ]);
  471.         }
  472.         $updatedCount $this->eventQualificationService->proposeAttendanceQualifications($event$this->getUser());
  473.         if ($updatedCount 0) {
  474.             $this->addFlash('success'sprintf('Se han propuesto %d calificaciones de asistencias.'$updatedCount));
  475.         } else {
  476.             $this->addFlash('info''No había calificaciones de asistencias pendientes de proponer.');
  477.         }
  478.         return $this->redirectToRoute('app_admin_view_event', [
  479.             'id' => $event->getId(),
  480.             'main_tab' => 'calificaciones',
  481.             'qualification_tab' => RegistrationQualification::TIPO_ASISTENCIAS,
  482.         ]);
  483.     }
  484.     #[Route(path'/admin/events/{id}/qualifications/final/propose'name'app_admin_event_propose_final_qualifications'methods: ['POST'])]
  485.     public function proposeFinalQualifications(Events $eventRequest $request): Response
  486.     {
  487.         if (!$this->RolesController->canEditQualifications($event)) {
  488.             $this->addFlash('error''No tienes permisos para editar este evento');
  489.             return $this->redirectToRoute('app_events');
  490.         }
  491.         $finalQualificationType RegistrationQualification::TIPO_CALIFICACION_FINAL;
  492.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  493.         if (!($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false)) {
  494.             $this->addFlash('error''La calificación final solo está disponible en cursos finalizados.');
  495.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  496.         }
  497.         $token = (string) $request->request->get('_token');
  498.         if (!$this->isCsrfTokenValid('propose_final_qualifications_'.$event->getId(), $token)) {
  499.             $this->addFlash('error''Token CSRF inválido.');
  500.             return $this->redirectToRoute('app_admin_view_event', [
  501.                 'id' => $event->getId(),
  502.                 'main_tab' => 'calificaciones',
  503.                 'qualification_tab' => $finalQualificationType,
  504.             ]);
  505.         }
  506.         $updatedCount $this->eventQualificationService->proposeFinalQualifications($event$this->getUser());
  507.         if ($updatedCount 0) {
  508.             $this->addFlash('success'sprintf('Se han propuesto %d calificaciones finales.'$updatedCount));
  509.         } else {
  510.             $this->addFlash('info''No había calificaciones finales pendientes de proponer.');
  511.         }
  512.         return $this->redirectToRoute('app_admin_view_event', [
  513.             'id' => $event->getId(),
  514.             'main_tab' => 'calificaciones',
  515.             'qualification_tab' => $finalQualificationType,
  516.         ]);
  517.     }
  518.     #[Route(
  519.         path'/admin/events/{id}/qualifications/{type}/template.csv',
  520.         name'app_admin_event_qualification_template_csv',
  521.         methods: ['GET'],
  522.         requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT]
  523.     )]
  524.     public function downloadQualificationTemplateCsv(Events $eventstring $type): Response
  525.     {
  526.         if (!$this->RolesController->canEditQualifications($event)) {
  527.             $this->addFlash('error''No tienes permisos para editar este evento');
  528.             return $this->redirectToRoute('app_events');
  529.         }
  530.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  531.         if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {
  532.             $this->addFlash('error''El tipo de calificación no está habilitado para este evento.');
  533.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  534.         }
  535.         $content $this->qualificationCsvService->buildTemplateContent($event$type);
  536.         $rawCode = (string) ($event->getExtCode() ?? 'evento');
  537.         $safeExtCode preg_replace('/[^A-Za-z0-9_\-]/''_'$rawCode);
  538.         $filename sprintf('%s-%s-plantilla-%s.csv'date('Ymd'), $safeExtCodestrtolower($type));
  539.         $response = new Response($content);
  540.         $response->headers->set('Content-Type''text/csv; charset=UTF-8');
  541.         $response->headers->set('Content-Disposition''attachment; filename="'.$filename.'"');
  542.         return $response;
  543.     }
  544.     #[Route(
  545.         path'/admin/events/{id}/qualifications/{type}/import.csv',
  546.         name'app_admin_event_import_qualifications_csv',
  547.         methods: ['POST'],
  548.         requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT]
  549.     )]
  550.     public function importQualificationsCsv(Events $eventstring $typeRequest $request): Response
  551.     {
  552.         if (!$this->RolesController->canEditQualifications($event)) {
  553.             $this->addFlash('error''No tienes permisos para editar este evento');
  554.             return $this->redirectToRoute('app_events');
  555.         }
  556.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  557.         if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {
  558.             $this->addFlash('error''El tipo de calificación no está habilitado para este evento.');
  559.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  560.         }
  561.         $token = (string) $request->request->get('_token');
  562.         if (!$this->isCsrfTokenValid('import_qualifications_csv_'.$event->getId().'_'.$type$token)) {
  563.             $this->addFlash('error''Token CSRF inválido.');
  564.             return $this->redirectToRoute('app_admin_view_event', [
  565.                 'id' => $event->getId(),
  566.                 'main_tab' => 'calificaciones',
  567.                 'qualification_tab' => $type,
  568.             ]);
  569.         }
  570.         $file $request->files->get('csv_file');
  571.         if (!$file) {
  572.             $this->addFlash('error''No se ha proporcionado ningún archivo CSV.');
  573.             return $this->redirectToRoute('app_admin_view_event', [
  574.                 'id' => $event->getId(),
  575.                 'main_tab' => 'calificaciones',
  576.                 'qualification_tab' => $type,
  577.             ]);
  578.         }
  579.         if (strtolower((string) $file->getClientOriginalExtension()) !== 'csv') {
  580.             $this->addFlash('error''El archivo debe tener formato CSV.');
  581.             return $this->redirectToRoute('app_admin_view_event', [
  582.                 'id' => $event->getId(),
  583.                 'main_tab' => 'calificaciones',
  584.                 'qualification_tab' => $type,
  585.             ]);
  586.         }
  587.         try {
  588.             $result $this->qualificationCsvService->importQualificationsFromCsv($event$type$file$this->getUser());
  589.         } catch (\Throwable $exception) {
  590.             $this->addFlash('error'$exception->getMessage());
  591.             return $this->redirectToRoute('app_admin_view_event', [
  592.                 'id' => $event->getId(),
  593.                 'main_tab' => 'calificaciones',
  594.                 'qualification_tab' => $type,
  595.             ]);
  596.         }
  597.         if (!empty($result['errors'])) {
  598.             $this->addFlash('error'sprintf('Se han detectado %d errores en el CSV. No se ha importado ningún cambio.'count($result['errors'])));
  599.             foreach (array_slice($result['errors'], 05) as $error) {
  600.                 $this->addFlash('error'$error);
  601.             }
  602.         } elseif ($result['updated_count'] > 0) {
  603.             $this->addFlash('success'sprintf('Se han importado %d calificaciones correctamente.'$result['updated_count']));
  604.         } else {
  605.             $this->addFlash('info''El fichero no contenía cambios para importar.');
  606.         }
  607.         if (empty($result['errors'])) {
  608.             $this->addQualificationWarnings($result['warnings'] ?? []);
  609.         }
  610.         return $this->redirectToRoute('app_admin_view_event', [
  611.             'id' => $event->getId(),
  612.             'main_tab' => 'calificaciones',
  613.             'qualification_tab' => $type,
  614.         ]);
  615.     }
  616.     #[Route(path'/admin/events/{id}/qualifications/final-grid'name'app_admin_event_save_final_qualifications'methods: ['POST'])]
  617.     public function saveFinalQualifications(Events $eventRequest $request): Response
  618.     {
  619.         if (!$this->RolesController->canEditQualifications($event)) {
  620.             $this->addFlash('error''No tienes permisos para editar este evento');
  621.             return $this->redirectToRoute('app_events');
  622.         }
  623.         $finalQualificationType RegistrationQualification::TIPO_CALIFICACION_FINAL;
  624.         $qualificationTypesConfig $this->getQualificationTypesConfig($event);
  625.         if (!($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false)) {
  626.             $this->addFlash('error''La calificación final solo está disponible en cursos finalizados.');
  627.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  628.         }
  629.         $token = (string) $request->request->get('_token');
  630.         if (!$this->isCsrfTokenValid('save_final_qualifications_'.$event->getId(), $token)) {
  631.             $this->addFlash('error''Token CSRF inválido.');
  632.             return $this->redirectToRoute('app_admin_view_event', [
  633.                 'id' => $event->getId(),
  634.                 'main_tab' => 'calificaciones',
  635.                 'qualification_tab' => $finalQualificationType,
  636.             ]);
  637.         }
  638.         $editableTypes array_keys(array_filter(
  639.             $qualificationTypesConfig,
  640.             static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false) && (bool) ($cfg['editable'] ?? false)
  641.         ));
  642.         $submittedQualifications $request->request->all('final_grid');
  643.         try {
  644.             $result $this->eventQualificationService->saveFinalGrid($event$submittedQualifications$editableTypes$this->getUser());
  645.             $this->addFlash('success''Calificaciones finales guardadas correctamente.');
  646.             $this->addQualificationWarnings($result['warnings'] ?? []);
  647.         } catch (\InvalidArgumentException $exception) {
  648.             $this->addFlash('error'$exception->getMessage());
  649.         }
  650.         return $this->redirectToRoute('app_admin_view_event', [
  651.             'id' => $event->getId(),
  652.             'main_tab' => 'calificaciones',
  653.             'qualification_tab' => $finalQualificationType,
  654.         ]);
  655.     }
  656.     #[Route(path'/admin/events/{id}/final-qualifications.xlsx'name'app_admin_event_export_final_qualifications'methods: ['GET'])]
  657.     public function exportFinalQualifications(Events $event): Response
  658.     {
  659.         if (!$this->RolesController->canExportReports($event)) {
  660.             $this->addFlash('error''No tienes permisos para acceder a este evento');
  661.             return $this->redirectToRoute('app_events');
  662.         }
  663.         $downloadAvailability $this->eventQualificationService->getDownloadAvailability($event);
  664.         if (!$downloadAvailability['available']) {
  665.             $this->addFlash(
  666.                 'error',
  667.                 $downloadAvailability['reason_code'] === 'event_not_finalized'
  668.                     'Solo se puede descargar este informe cuando el curso está finalizado.'
  669.                     self::FINAL_QUALIFICATIONS_DOWNLOAD_BLOCKED_MESSAGE
  670.             );
  671.             return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);
  672.         }
  673.         $registrations $event->getActiveRegistrations();
  674.         $qualificationTypes = [
  675.             RegistrationQualification::TIPO_ASISTENCIAS,
  676.             RegistrationQualification::TIPO_CUESTIONARIO,
  677.             RegistrationQualification::TIPO_PRACTICAS,
  678.             RegistrationQualification::TIPO_CALIFICACION_FINAL,
  679.         ];
  680.         $existingQualifications $this->registrationQualificationRepository->findByEventAndTypes($event$qualificationTypes);
  681.         $qualificationsMap = [];
  682.         foreach ($existingQualifications as $qualification) {
  683.             $registration $qualification->getRegistration();
  684.             if ($registration === null || $registration->getId() === null) {
  685.                 continue;
  686.             }
  687.             $qualificationsMap[$registration->getId()][$qualification->getTipoCalificacion()] = [
  688.                 'value' => $qualification->getCalificacion(),
  689.                 'comment' => $qualification->getObservaciones(),
  690.                 'penalty_reason_type' => $qualification->getPenaltyReason()?->getCode() ?? $qualification->getMotivoPenalizacionTipo(),
  691.             ];
  692.         }
  693.         $penaltyReasonOptions $this->eventQualificationService->getAllPenaltyReasonLabels();
  694.         $spreadsheet = new Spreadsheet();
  695.         $sheet $spreadsheet->getActiveSheet();
  696.         $headers = [
  697.             'Código Curso EXT',
  698.             'Nombre Curso',
  699.             'Fecha Inicio Curso',
  700.             'Fecha Fin Curso',
  701.             'Número Horas',
  702.             'DNI Alumno',
  703.             'Nombre',
  704.             'Apellidos',
  705.             'Calificación Asistencia',
  706.             'Calificación Cuestionario',
  707.             'Calificación Prácticas',
  708.             'Calificación Final',
  709.             'Observaciones Finales',
  710.             'Motivo Penalización Final',
  711.         ];
  712.         $sheet->fromArray($headersnull'A1');
  713.         $row 2;
  714.         foreach ($registrations as $registration) {
  715.             $user $registration->getUserId();
  716.             $profile $user?->getProfile();
  717.             $name $profile?->getNombre() ?? $user?->getNomComplet();
  718.             $surname $user?->getSurname();
  719.             if ($profile && $profile->getApellido1() !== '') {
  720.                 $surname $profile->getApellido1();
  721.                 if ($profile->getApellido2() !== '') {
  722.                     $surname .= ' '.$profile->getApellido2();
  723.                 }
  724.             }
  725.             $registrationId $registration->getId();
  726.             $asistencias $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_ASISTENCIAS]['value'] ?? '') : '';
  727.             $cuestionario $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_CUESTIONARIO]['value'] ?? '') : '';
  728.             $practicas $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_PRACTICAS]['value'] ?? '') : '';
  729.             $finalQualificationData $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_CALIFICACION_FINAL] ?? null) : null;
  730.             $final $finalQualificationData['value'] ?? '';
  731.             $finalComment $finalQualificationData['comment'] ?? '';
  732.             $finalPenaltyReason '';
  733.             if (!empty($finalQualificationData['penalty_reason_type'])) {
  734.                 $finalPenaltyReason $penaltyReasonOptions[$finalQualificationData['penalty_reason_type']] ?? (string) $finalQualificationData['penalty_reason_type'];
  735.             }
  736.             $sheet->fromArray([
  737.                 (string) ($event->getExtCode() ?? ''),
  738.                 (string) ($event->getName() ?? ''),
  739.                 $event->getStartDate()?->format('d-m-Y') ?? '',
  740.                 $event->getEndDate()?->format('d-m-Y') ?? '',
  741.                 (string) ($event->getHours() ?? ''),
  742.                 (string) ($registration->getDni() ?? ''),
  743.                 (string) ($name ?? ''),
  744.                 (string) ($surname ?? ''),
  745.                 (string) ($asistencias ?? ''),
  746.                 (string) ($cuestionario ?? ''),
  747.                 (string) ($practicas ?? ''),
  748.                 (string) ($final ?? ''),
  749.                 (string) ($finalComment ?? ''),
  750.                 (string) ($finalPenaltyReason ?? ''),
  751.             ], null'A'.$row);
  752.             $row++;
  753.         }
  754.         foreach ($sheet->getColumnIterator() as $column) {
  755.             $sheet->getColumnDimension($column->getColumnIndex())->setAutoSize(true);
  756.         }
  757.         $sheet->getStyle('A1:N1')->getFont()->setBold(true);
  758.         $rawCode = (string) ($event->getExtCode() ?? 'evento');
  759.         $safeExtCode preg_replace('/[^A-Za-z0-9_\-]/''_'$rawCode);
  760.         $fileName = (new \DateTime())->format('Ymd').'-'.$safeExtCode.'-calificaciones-finales.xlsx';
  761.         $tmpFile tempnam(sys_get_temp_dir(), 'final_qual_');
  762.         if ($tmpFile === false) {
  763.             throw new \RuntimeException('No se pudo crear el fichero temporal para la exportación.');
  764.         }
  765.         $writer = new Xlsx($spreadsheet);
  766.         $writer->save($tmpFile);
  767.         $response = new BinaryFileResponse($tmpFile200, [
  768.             'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  769.         ]);
  770.         $response->setContentDisposition('attachment'$fileName);
  771.         $response->deleteFileAfterSend(true);
  772.         return $response;
  773.     }
  774.     #[Route(path'/admin/events/edit/{id}'name'app_admin_edit_event')]
  775.     public function editEvent(Events $eventRequest $request)
  776.     {
  777.         if (!$this->RolesController->canEditEvent($event)) {
  778.             $this->addFlash('error''No tienes permisos para editar este evento');
  779.             return $this->redirectToRoute('app_events');
  780.         }
  781.         $form $this->createForm(EventsFormType::class, $event);
  782.         $form->handleRequest($request);
  783.         if ($form->isSubmitted() && $form->isValid()) {
  784.             if($event->getNumberCheckings() == null){ //Si el número necesario de fichajes está vacío lo calculamos 
  785.                 if($event->getShift() == "" || $event->getShift() == "1") {
  786.                     $numberCheckings $event->getTotalDays() * 1//Si no está especificado el tipo de jornada o si la jornada es contínua el número de fichajes es 1 por día.
  787.                 }else{
  788.                     $numberCheckings $event->getTotalDays() * 2
  789.                 }
  790.                 $event->setNumberCheckings($numberCheckings);
  791.             }
  792.             // if($event->getLocation() == null ? $event->setLocation(""): $event->getLocation() );
  793.             $imageFile $form->get('imageFile')->getData();
  794.             if ($imageFile) {
  795.                 $newFilename uniqid().'.'.$imageFile->guessExtension();
  796.                 $imageFile->move($this->getParameter('kernel.project_dir').'/public/images'$newFilename);
  797.                 $event->setImage($newFilename);
  798.             }
  799.             if ($request->request->get('remove_image')) {
  800.                 $event->setImage(null);
  801.             }
  802.             $event->setUpdated((new \DateTime()));
  803.             $this->applyAutomaticEventOwnership($event);
  804.             if (!$this->ensureEventCenterIsResolved($event$form)) {
  805.                 return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  806.             }
  807.             if (!$this->ensureEventAssignmentsAreValid($event$form)) {
  808.                 return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  809.             }
  810.             $this->entityManager->persist($event);
  811.             $this->entityManager->flush();
  812.             $this->addFlash('success''Evento guardado');
  813.             $this->addTeacherHourWarningFlashes($event);
  814.             return $this->redirectToRoute('app_events');
  815.         }
  816.         return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);
  817.         
  818.     }
  819.     #[Route(path'/admin/events/delete/{id}'name'app_admin_delete_event')]
  820.     public function deleteEvent($id)
  821.     {
  822.         $event $this->eventsRepository->findOneBy(['id' => $id]);
  823.         if (!$event instanceof Events || !$this->RolesController->canDeleteEvent($event)) {
  824.             $this->addFlash('error''No tienes permisos para eliminar este evento');
  825.             return $this->redirectToRoute('app_events');
  826.         }
  827.         $eventRegistrations $event->getRegistrations();
  828.         
  829.         $numRegistrations 0;
  830.         $numAttendances 0;
  831.         foreach ($eventRegistrations as $registration){
  832.             $eventAttendances $registration->getAttendances();
  833.             foreach($eventAttendances as $attendance){
  834.                 $numAttendances++;
  835.                 $this->entityManager->remove($attendance);
  836.             }
  837.             $numRegistrations++;
  838.             $this->entityManager->remove($registration);
  839.         }    
  840.         $this->entityManager->remove($event);
  841.         $this->entityManager->flush();
  842.         $this->addFlash('success''Se ha eliminado el Evento junto con '.$numRegistrations.' inscripciones y '.$numAttendances.' asistencias');
  843.         return $this->redirectToRoute('app_events');
  844.     }
  845.     #[Route(path'/admin/events/close/{id}'name'app_admin_close_event')]
  846.     public function closeEvent(Request $request$id)
  847.     {
  848.         $event $this->eventsRepository->find($id);
  849.         if (!$event instanceof Events || !$this->RolesController->canFinalizeEvent($event)) {
  850.             $this->addFlash('error''No tienes permisos para cerrar este evento');
  851.             return $this->redirectToRoute('app_events');
  852.         }
  853.         
  854.         $this->NotificationsController->sendCloseEventNotifications($event);
  855.         $event->setUpdated((new \DateTime()));
  856.         $event->setStatus(EventStatus::FINALIZADO);
  857.         $this->entityManager->persist($event);
  858.         $this->entityManager->flush();
  859.         $this->addFlash('success''Evento cerrado correctamente');
  860.         return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  861.     }
  862.     #[Route(path'/admin/events/open/{id}'name'app_admin_open_event')]
  863.     public function openEvent(Request $request$id)
  864.     {
  865.         $event $this->eventsRepository->find($id);
  866.         if (!$event instanceof Events || !$this->RolesController->canReopenEvent($event)) {
  867.             $this->addFlash('error''No tienes permisos para abrir este evento');
  868.             return $this->redirectToRoute('app_events');
  869.         }
  870.         $event->setUpdated((new \DateTime()));
  871.         $event->setStatus(EventStatus::ABIERTO);
  872.         
  873.         $this->entityManager->persist($event);
  874.         $this->entityManager->flush();
  875.         $this->addFlash('success''Evento abierto correctamente');
  876.         return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  877.     }
  878.     #[Route(path'/admin/events/{id}/force-status'name'app_admin_force_event_status'methods: ['POST'])]
  879.     public function forceEventStatus(Request $requestint $id): Response
  880.     {
  881.         if (!$this->isCsrfTokenValid('force_event_status_'.$id$request->request->get('_token'))) {
  882.             $this->addFlash('error''Token CSRF inválido');
  883.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  884.         }
  885.         $event $this->eventsRepository->find($id);
  886.         if (!$event) {
  887.             $this->addFlash('error''Evento no encontrado');
  888.             return $this->redirectToRoute('app_events');
  889.         }
  890.         $allow $this->RolesController->checkAccessToEvent($event);
  891.         if ($allow == false) {
  892.             $this->addFlash('error''No tienes permisos para acceder a este evento');
  893.             return $this->redirectToRoute('app_events');
  894.         }
  895.         if (!$this->RolesController->canForceEventStatus($event)) {
  896.             $this->addFlash('error''No tienes permisos para forzar el estado del evento');
  897.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  898.         }
  899.         $statusValue = (string) $request->request->get('status''');
  900.         $status EventStatus::tryFrom($statusValue);
  901.         if (!$status) {
  902.             $this->addFlash('error''Estado no válido');
  903.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  904.         }
  905.         $adjustmentRequired $this->eventInscriptionEndDateAdjustmentService->requiresAdjustment($event$status);
  906.         $confirmInscriptionEndAdjustment $request->request->getBoolean('confirm_inscription_end_adjustment');
  907.         if ($adjustmentRequired && !$confirmInscriptionEndAdjustment) {
  908.             $adjustmentContext $this->eventInscriptionEndDateAdjustmentService->buildAdjustmentContext($event$status);
  909.             $this->addFlash(
  910.                 'warning',
  911.                 sprintf(
  912.                     'Debes confirmar el cierre manual de inscripciones. La fecha fin de inscripciones pasará de %s a %s.',
  913.                     $adjustmentContext['currentLabel'] ?? 'sin fecha',
  914.                     $adjustmentContext['newLabel']
  915.                 )
  916.             );
  917.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  918.         }
  919.         $inscriptionEndAdjusted $this->eventInscriptionEndDateAdjustmentService->applyAdjustmentIfRequired($event$status);
  920.         $event->setStatus($status);
  921.         $event->setUpdated(new \DateTime());
  922.         $this->entityManager->persist($event);
  923.         $this->entityManager->flush();
  924.         if ($inscriptionEndAdjusted) {
  925.             $this->addFlash(
  926.                 'success',
  927.                 sprintf(
  928.                     'Estado del evento actualizado. La fecha fin de inscripciones se ha fijado en %s.',
  929.                     $event->getInscripcionesFin()?->format('d/m/Y') ?? (new \DateTimeImmutable('today'))->format('d/m/Y')
  930.                 )
  931.             );
  932.         } else {
  933.             $this->addFlash('success''Estado del evento actualizado.');
  934.         }
  935.         return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  936.     }
  937.     #[Route(path'/admin/events/sendregistrations/{id}'name'app_admin_send_registrations_event')]
  938.     public function sendRegistrations(Request $requestMessageBusInterface $bus$id)
  939.     {
  940.         if(
  941.             in_array('ROLE_SUPERUSER'$this->getUser()->getRoles(), true)
  942.             || in_array('ROLE_CENTER'$this->getUser()->getRoles(), true)
  943.             || in_array('ROLE_MANAGER'$this->getUser()->getRoles(), true)
  944.         ){
  945.             $event $this->eventsRepository->find($id);
  946.             if (!$event instanceof Events || !$this->RolesController->checkAccessToEvent($event)) {
  947.                 $this->addFlash('error'"El usuario logueado no tiene permisos para realizar esta acción en el evento");
  948.                 return $this->redirectToRoute('app_events');
  949.             }
  950.             if (!$this->RolesController->hasCapability('VIEW_REGISTRATIONS')) {
  951.                 $this->addFlash('error''No tienes permisos para enviar las inscripciones del evento');
  952.                 return $this->redirectToRoute('app_events');
  953.             }
  954.        
  955.             $eventRegistrations $event->getActiveRegistrations();
  956.             $urlbase $this->paramsRepository->findOneBy(criteria: ['param' => "URL_BASE"])->getValue();
  957.             $successCount 0;
  958.             $errorCount   0;
  959.             foreach ($eventRegistrations as $registration){
  960.                 $emailTo $registration->getUserId()->getEmail();
  961.                 $registration->urlQrCode $urlbase.$registration->getQrCode();              
  962.                 $emailTemplate 'notifications/registration.html.twig';
  963.                 $emailSubject 'Inscripción: ' $event->getName();
  964.                 $nameTo $registration->getUserId()->getNomComplet();
  965.                 $emailContent = [
  966.                     'registrationEventName'    => $event->getName(),
  967.                     'registrationUserName'     => $registration->getUserId()->getNomComplet() . ' ' $registration->getUserId()->getSurname(),
  968.                     'registrationUserDni'      => $registration->getDni(),
  969.                     'registrationEventStartDate' => $event->getStartDate(),
  970.                     'registrationEventEndDate'   => $event->getEndDate(),
  971.                     'registrationUrlQrCode'    => $registration->urlQrCode,
  972.                     // 'registrationDetailedSpecs' => $event->getDetailedSpecs()
  973.                 ];
  974.                 try {
  975.                     $this->bus->dispatch(new SendEmailMessage(
  976.                         $emailTo,
  977.                         $emailTemplate,
  978.                         $emailSubject,
  979.                         $nameTo,
  980.                         $emailContent
  981.                     ));
  982.                     $successCount++;
  983.                 } catch (\Exception $e) {
  984.                     $errorCount++;
  985.                 }
  986.             }
  987.         
  988.             if ($errorCount === 0) {
  989.                 $this->addFlash('success'"Se han enviado las inscripciones a {$successCount} usuarios correctamente.");
  990.             } elseif ($successCount === 0) {
  991.                 $this->addFlash('error'"Se ha producido un error en el envío de las inscripciones a {$errorCount} usuarios.");
  992.             } else {
  993.                 $this->addFlash('warning'"Se han enviado las inscripciones correctamente a {$successCount} usuarios, pero se han producido errores en {$errorCount} inscripciones.");
  994.             }
  995.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  996.         }
  997.         $this->addFlash('error''No tienes permisos para enviar las inscripciones del evento');
  998.         return $this->redirectToRoute('app_events');
  999.     }
  1000.     
  1001.     #[Route('/acciones-formativas'name'public_events')]
  1002.     public function publicEvents(Request $requestPaginatorInterface $paginator,TaxonomyValueRepository $taxonomyValueRepositoryHomeSliderRepository $homeSliderRepository): Response
  1003.     {
  1004.         
  1005.         $view $request->query->get('view''list');
  1006.         
  1007.         $modalidadId $request->query->get('modalidad');
  1008.         $modalidad $modalidadId
  1009.             $taxonomyValueRepository->find($modalidadId)
  1010.             : null;
  1011.         $areaTematicaId $request->query->get('areaTematica');
  1012.         $areaTematica $areaTematicaId
  1013.             $taxonomyValueRepository->find($areaTematicaId)
  1014.             : null;
  1015.         $openInscription $request->query->getBoolean('open_inscription');
  1016.         // Parámetros de filtro (si vienen vacíos se mantienen en null)
  1017.         $start_from     $request->query->get('start_from') ?: null;
  1018.         $start_to       $request->query->get('start_to') ?: null;
  1019.         $search         $request->query->get('q') ?: null;
  1020.         $code           $request->query->get('code') ?: null;
  1021.         $localidad      $request->query->get('localidad') ?: null;
  1022.         $duration_from $request->query->get('durationFrom') ?: null;
  1023.         $duration_to   $request->query->get('durationTo') ?: null;
  1024.         
  1025.         // Query filtrada (repositorio)
  1026.         $query $this->eventsRepository->getPublicFilteredQuery(
  1027.             $search,
  1028.             $code,
  1029.             $modalidad,
  1030.             $areaTematica,
  1031.             $localidad,
  1032.             $start_from,
  1033.             $start_to,
  1034.             $duration_from,
  1035.             $duration_to,
  1036.             $openInscription
  1037.         );
  1038.         $view $request->query->get('view''list');
  1039.         // Paginación (KNP paginator) para listado
  1040.         $events $paginator->paginate(
  1041.             $query,
  1042.             $request->query->getInt('page'1),
  1043.             6
  1044.         );
  1045.         // Sin paginación para calendario (consulta independiente)
  1046.         $eventsCalendar = [];
  1047.         if ($view === 'calendar') {
  1048.             $calendarQuery $this->eventsRepository->getPublicFilteredQuery(
  1049.                 $search,
  1050.                 $code,
  1051.                 $modalidad,
  1052.                 $areaTematica,
  1053.                 $localidad,
  1054.                 $start_from,
  1055.                 $start_to,
  1056.                 $duration_from,
  1057.                 $duration_to,
  1058.                 $openInscription
  1059.             );
  1060.             $eventsCalendar $calendarQuery->getResult();
  1061.         }
  1062.         // Lista de localidades para el select (simple: array vacío si no existe)
  1063.         // Si quieres poblar esto desde otra entidad, reemplaza por la consulta concreta.
  1064.         $localidades = [];
  1065.         $modalidades $taxonomyValueRepository->findByTaxonomyLabel('Modalidad');
  1066.         $areasTematicas $taxonomyValueRepository->findByTaxonomyLabel('Area Tematica');
  1067.         $hoursRange $this->eventsRepository->getHoursRange();
  1068.         return $this->render('public/events/home.html.twig', [
  1069.             'events'        => $events,
  1070.             'eventsCalendar' => $eventsCalendar,
  1071.             'search'        => $search,
  1072.             'view'          => $view,
  1073.             'code'          => $code,
  1074.             'modalidad'     => $modalidad,
  1075.             'modalidades' => $modalidades,
  1076.             'areaTematica' => $areaTematica,
  1077.             'areasTematicas' => $areasTematicas,
  1078.             'localidad'     => $localidad,
  1079.             'start_from'    => $start_from,
  1080.             'start_to'      => $start_to,
  1081.             'duration_from' => $duration_from,
  1082.             'duration_to'   => $duration_to,
  1083.             'localidades'   => $localidades,
  1084.             'hoursRange' => $hoursRange,
  1085.             'sliderItems' => $homeSliderRepository->findEnabledOrdered(),
  1086.         ]);
  1087.     }
  1088.     #[Route('/acciones-formativas/{extCode}'name'events_public_ficha')]
  1089.     public function ficha(
  1090.         string $extCode,
  1091.         EventsRepository $eventsRepository,
  1092.         EventRequestRepository $eventRequestRepository
  1093.     ): Response {
  1094.         $event $eventsRepository->findPublicByExtCode($extCode);
  1095.         if (!$event) {
  1096.             throw $this->createNotFoundException();
  1097.         }
  1098.         $user $this->getUser();
  1099.         $hasRequest false;
  1100.         if ($user) {
  1101.             $hasRequest = (bool) $eventRequestRepository->findOneByEventAndUser($event$user);
  1102.         }
  1103.         return $this->render('public/events/event_public_info.html.twig', [
  1104.             'event' => $event,
  1105.             'hasRequest' => $hasRequest
  1106.         ]);
  1107.     }
  1108.     
  1109.     #[Route('/acciones-formativas/{id}/pdf'name'event_public_pdf')]
  1110.     public function publicPdf(Events $event): Response
  1111.     {
  1112.     if (!$event->isPublished()) {
  1113.         throw $this->createNotFoundException();
  1114.     }
  1115.     $logoPdf null;
  1116.     $logoParam $this->paramsRepository->findOneBy(['param' => 'LOGO_PDF']);
  1117.     if ($logoParam && $logoParam->getValue()) {
  1118.         $logoFile ltrim($logoParam->getValue(), '/');
  1119.         $logoPath $this->getParameter('kernel.project_dir').'/public/'.$logoFile;
  1120.         if (is_file($logoPath)) {
  1121.             $ext strtolower(pathinfo($logoPathPATHINFO_EXTENSION));
  1122.             $mime = match ($ext) {
  1123.                 'jpg''jpeg' => 'image/jpeg',
  1124.                 'gif' => 'image/gif',
  1125.                 'svg' => 'image/svg+xml',
  1126.                 default => 'image/png',
  1127.             };
  1128.             $logoPdf 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($logoPath));
  1129.         }
  1130.     }
  1131.     $html $this->renderView('public/events/event_public_info_pdf.html.twig', [
  1132.         'event' => $event,
  1133.         'logoPdf' => $logoPdf,
  1134.     ]);
  1135.     $options = new Options();
  1136.     $options->set('isHtml5ParserEnabled'true);
  1137.     $options->set('isPhpEnabled'true);
  1138.     $dompdf = new Dompdf($options);
  1139.     $dompdf->loadHtml($html);
  1140.     $dompdf->setPaper('A4''portrait');
  1141.     $dompdf->render();
  1142.     $fileName date('Ymd') . '-' $event->getExtCode() . '.pdf';
  1143.     return new Response(
  1144.         $dompdf->output(),
  1145.         200,
  1146.         [
  1147.             'Content-Type' => 'application/pdf',
  1148.             'Content-Disposition' => 'attachment; filename="'.$fileName.'"'
  1149.         ]
  1150.     );
  1151. }
  1152.     /**
  1153.      * @return array<int>
  1154.      */
  1155.     private function getVisibleEventIds(): array
  1156.     {
  1157.         $eventIds = [];
  1158.         foreach ($this->RolesController->getEventsByUserAndRol() as $event) {
  1159.             $eventId $event->getId();
  1160.             if ($eventId === null) {
  1161.                 continue;
  1162.             }
  1163.             $eventIds[$eventId] = $eventId;
  1164.         }
  1165.         return array_values($eventIds);
  1166.     }
  1167.     /**
  1168.      * @return array{0: string, 1: string}
  1169.      */
  1170.     private function resolveEventDataTableOrdering(Request $request): array
  1171.     {
  1172.         $query $request->query->all();
  1173.         $order $query['order'][0] ?? [];
  1174.         $columns $query['columns'] ?? [];
  1175.         $columnIndex = (int) ($order['column'] ?? 0);
  1176.         $column $columns[$columnIndex] ?? [];
  1177.         $columnName = (string) ($column['name'] ?? $column['data'] ?? 'startDate');
  1178.         return [
  1179.             $this->sanitizeEventOrderColumn($columnName),
  1180.             $this->sanitizeEventOrderDirection((string) ($order['dir'] ?? 'desc')),
  1181.         ];
  1182.     }
  1183.     /**
  1184.      * @return array{0: string, 1: string}
  1185.      */
  1186.     private function resolveEventExportOrdering(Request $request): array
  1187.     {
  1188.         return [
  1189.             $this->sanitizeEventOrderColumn((string) $request->query->get('orderColumn''startDate')),
  1190.             $this->sanitizeEventOrderDirection((string) $request->query->get('orderDir''desc')),
  1191.         ];
  1192.     }
  1193.     /**
  1194.      * @return array<string, bool>
  1195.      */
  1196.     private function buildEventActionVisibility(): array
  1197.     {
  1198.         return [
  1199.             'canView' => $this->RolesController->hasCapability('VIEW_EVENTS'),
  1200.             'canEdit' => $this->RolesController->hasCapability('EDIT_EVENTS'),
  1201.             'canDelete' => $this->RolesController->hasCapability('DELETE_EVENTS'),
  1202.         ];
  1203.     }
  1204.     /**
  1205.      * @param array<string, mixed> $row
  1206.      * @param array<string, bool> $actionVisibility
  1207.      * @return array<string, mixed>
  1208.      */
  1209.     private function buildEventTableRow(array $row, array $actionVisibility): array
  1210.     {
  1211.         $eventId = (int) ($row['id'] ?? 0);
  1212.         $statusValue $row['status'] ?? '';
  1213.         if ($statusValue instanceof EventStatus) {
  1214.             $statusValue $statusValue->value;
  1215.         }
  1216.         $statusValue = (string) $statusValue;
  1217.         $registrationMetrics $this->buildEventRegistrationMetrics($row);
  1218.         return [
  1219.             'id' => $eventId,
  1220.             'published' => (bool) ($row['published'] ?? false),
  1221.             'publishedLabel' => (bool) ($row['published'] ?? false) ? 'Publicado' 'Oculto',
  1222.             'publishedTone' => (bool) ($row['published'] ?? false) ? 'published' 'hidden',
  1223.             'statusLabel' => $this->formatEventTableStatusLabel($statusValue),
  1224.             'statusTone' => $this->resolveEventStatusTone($statusValue),
  1225.             'extCode' => trim((string) ($row['extCode'] ?? '')),
  1226.             'name' => trim((string) ($row['name'] ?? '')),
  1227.             'startDate' => $this->formatEventDate($row['startDate'] ?? null),
  1228.             'startDateSort' => $this->formatEventDateSortValue($row['startDate'] ?? null),
  1229.             'endDate' => $this->formatEventDate($row['endDate'] ?? null),
  1230.             'endDateSort' => $this->formatEventDateSortValue($row['endDate'] ?? null),
  1231.             'requestCount' => max(0, (int) ($row['requestCount'] ?? 0)),
  1232.             'registrationCount' => $registrationMetrics['registrationCount'],
  1233.             'participantLimit' => $registrationMetrics['registrationLimit'],
  1234.             'registrationSummary' => $registrationMetrics['registrationSummary'],
  1235.             'registrationPercentLabel' => $registrationMetrics['registrationPercentLabel'],
  1236.             'registrationTone' => $registrationMetrics['registrationTone'],
  1237.             'registrationExceeded' => $registrationMetrics['registrationExceeded'],
  1238.             'registrationDetail' => $registrationMetrics['registrationDetail'],
  1239.             'canView' => $actionVisibility['canView'],
  1240.             'canEdit' => $actionVisibility['canEdit'],
  1241.             'canDelete' => $actionVisibility['canDelete'],
  1242.             'viewUrl' => $eventId $this->generateUrl('app_admin_view_event', ['id' => $eventId]) : null,
  1243.             'editUrl' => $eventId $this->generateUrl('app_admin_edit_event', ['id' => $eventId]) : null,
  1244.             'deleteUrl' => $eventId $this->generateUrl('app_admin_delete_event', ['id' => $eventId]) : null,
  1245.             'deleteLabel' => trim(((string) ($row['extCode'] ?? '') !== '' ? (string) $row['extCode'].' · ' '').((string) ($row['name'] ?? '') !== '' ? (string) $row['name'] : 'evento seleccionado')),
  1246.         ];
  1247.     }
  1248.     /**
  1249.      * @param array<string, mixed> $row
  1250.      * @return array{
  1251.      *     registrationCount:int,
  1252.      *     registrationLimit:?int,
  1253.      *     registrationSummary:string,
  1254.      *     registrationPercentLabel:string,
  1255.      *     registrationTone:string,
  1256.      *     registrationExceeded:bool,
  1257.      *     registrationDetail:string
  1258.      * }
  1259.      */
  1260.     private function buildEventRegistrationMetrics(array $row): array
  1261.     {
  1262.         $registrationCount max(0, (int) ($row['registrationCount'] ?? 0));
  1263.         $participants max(0, (int) ($row['participants'] ?? 0));
  1264.         $maxRegistrations max(0, (int) ($row['maxRegistrations'] ?? 0));
  1265.         $registrationLimit $maxRegistrations 0
  1266.             $maxRegistrations
  1267.             : ($participants $participants null);
  1268.         if ($registrationLimit !== null) {
  1269.             $registrationPercent = ($registrationCount $registrationLimit) * 100;
  1270.             $registrationSummary $registrationCount.'/'.$registrationLimit;
  1271.             $registrationPercentLabel number_format($registrationPercent2',''.').' %';
  1272.             $registrationTone $this->resolveEventRegistrationTone($registrationPercent);
  1273.             $registrationExceeded $registrationPercent 100;
  1274.             $registrationDetail $maxRegistrations 0
  1275.                 sprintf('%d de %d plazas ocupadas'$registrationCount$registrationLimit)
  1276.                 : sprintf('%d de %d participantes previstos'$registrationCount$registrationLimit);
  1277.             return [
  1278.                 'registrationCount' => $registrationCount,
  1279.                 'registrationLimit' => $registrationLimit,
  1280.                 'registrationSummary' => $registrationSummary,
  1281.                 'registrationPercentLabel' => $registrationPercentLabel,
  1282.                 'registrationTone' => $registrationTone,
  1283.                 'registrationExceeded' => $registrationExceeded,
  1284.                 'registrationDetail' => $registrationDetail,
  1285.             ];
  1286.         }
  1287.         return [
  1288.             'registrationCount' => $registrationCount,
  1289.             'registrationLimit' => null,
  1290.             'registrationSummary' => (string) $registrationCount,
  1291.             'registrationPercentLabel' => 'No aplica',
  1292.             'registrationTone' => 'normal',
  1293.             'registrationExceeded' => false,
  1294.             'registrationDetail' => 'Sin cupo máximo configurado',
  1295.         ];
  1296.     }
  1297.     private function sanitizeEventOrderColumn(string $column): string
  1298.     {
  1299.         return in_array($column, ['published''status''extCode''name''startDate''endDate''requests''registrations'], true)
  1300.             ? $column
  1301.             'startDate';
  1302.     }
  1303.     private function sanitizeEventOrderDirection(string $direction): string
  1304.     {
  1305.         return strtoupper($direction) === 'ASC' 'ASC' 'DESC';
  1306.     }
  1307.     private function formatEventDate(mixed $value): string
  1308.     {
  1309.         if ($value instanceof \DateTimeInterface) {
  1310.             return $value->format('d-m-Y');
  1311.         }
  1312.         if (is_string($value) && trim($value) !== '') {
  1313.             try {
  1314.                 return (new \DateTimeImmutable($value))->format('d-m-Y');
  1315.             } catch (\Throwable) {
  1316.                 return $value;
  1317.             }
  1318.         }
  1319.         return 'Sin fecha';
  1320.     }
  1321.     private function formatEventDateSortValue(mixed $value): int
  1322.     {
  1323.         if ($value instanceof \DateTimeInterface) {
  1324.             return (int) $value->format('U');
  1325.         }
  1326.         if (is_string($value) && trim($value) !== '') {
  1327.             try {
  1328.                 return (new \DateTimeImmutable($value))->getTimestamp();
  1329.             } catch (\Throwable) {
  1330.                 return 0;
  1331.             }
  1332.         }
  1333.         return 0;
  1334.     }
  1335.     private function formatEventStatusLabel(string $status): string
  1336.     {
  1337.         return match ($status) {
  1338.             EventStatus::ABIERTO->value => 'Abierto',
  1339.             EventStatus::INSCRIPCIONES_CERRADAS->value => 'Inscripciones cerradas',
  1340.             EventStatus::EN_CURSO->value => 'En curso',
  1341.             EventStatus::FINALIZADO->value => 'Finalizado',
  1342.             default => trim(ucwords(strtolower(str_replace('_'' '$status)))),
  1343.         };
  1344.     }
  1345.     private function formatEventTableStatusLabel(string $status): string
  1346.     {
  1347.         return match ($status) {
  1348.             EventStatus::INSCRIPCIONES_CERRADAS->value => 'INS. CERRADAS',
  1349.             default => $this->formatEventStatusLabel($status),
  1350.         };
  1351.     }
  1352.     private function resolveEventStatusTone(string $status): string
  1353.     {
  1354.         return match ($status) {
  1355.             EventStatus::ABIERTO->value => 'open',
  1356.             EventStatus::INSCRIPCIONES_CERRADAS->value => 'warning',
  1357.             EventStatus::EN_CURSO->value => 'progress',
  1358.             EventStatus::FINALIZADO->value => 'finished',
  1359.             default => 'closed',
  1360.         };
  1361.     }
  1362.     private function resolveEventRegistrationTone(float $registrationPercent): string
  1363.     {
  1364.         if ($registrationPercent 100) {
  1365.             return 'overflow';
  1366.         }
  1367.         if (abs($registrationPercent 100.0) < 0.0001) {
  1368.             return 'full';
  1369.         }
  1370.         return 'normal';
  1371.     }
  1372.     private function getQualificationTypesConfig(Events $event): array
  1373.     {
  1374.         return $this->eventQualificationService->getQualificationTypesConfig($event);
  1375.     }
  1376.     /**
  1377.      * @param string[] $warnings
  1378.      */
  1379.     private function addQualificationWarnings(array $warnings): void
  1380.     {
  1381.         foreach (array_slice($warnings05) as $warning) {
  1382.             $this->addFlash('warning'$warning);
  1383.         }
  1384.         if (count($warnings) > 5) {
  1385.             $this->addFlash('warning'sprintf('Hay %d advertencias adicionales relacionadas con penalizaciones activas.'count($warnings) - 5));
  1386.         }
  1387.     }
  1388.     /**
  1389.      * @return array<int, array{
  1390.      *     teacherId:int,
  1391.      *     teacherName:string,
  1392.      *     isInternal:bool,
  1393.      *     accumulatedHours:int,
  1394.      *     projectedHours:int,
  1395.      *     warning:bool,
  1396.      *     message:string
  1397.      * }>
  1398.      */
  1399.     private function buildTeacherHourWarningsForEvent(Events $event): array
  1400.     {
  1401.         $teacherIds = [];
  1402.         foreach ($event->getCourseUser() as $teacher) {
  1403.             $teacherId $teacher->getId();
  1404.             if ($teacherId === null) {
  1405.                 continue;
  1406.             }
  1407.             $teacherIds[] = $teacherId;
  1408.         }
  1409.         if ($teacherIds === []) {
  1410.             return [];
  1411.         }
  1412.         $startDate $event->getStartDate();
  1413.         $year $startDate instanceof \DateTimeInterface ? (int) $startDate->format('Y') : null;
  1414.         return $this->teacherHourWarningService->buildWarnings(
  1415.             $teacherIds,
  1416.             $year,
  1417.             $event->getId(),
  1418.             $event->getHours()
  1419.         );
  1420.     }
  1421.     private function addTeacherHourWarningFlashes(Events $event): void
  1422.     {
  1423.         foreach ($this->teacherHourWarningService->buildFlashMessages($this->buildTeacherHourWarningsForEvent($event)) as $message) {
  1424.             $this->addFlash('warning'$message);
  1425.         }
  1426.     }
  1427.     private function resolveTeacherWarningYear(mixed $value): ?int
  1428.     {
  1429.         if ($value instanceof \DateTimeInterface) {
  1430.             return (int) $value->format('Y');
  1431.         }
  1432.         $normalizedValue trim((string) ($value ?? ''));
  1433.         if (!preg_match('/^\d{4}$/'$normalizedValue)) {
  1434.             return null;
  1435.         }
  1436.         return (int) $normalizedValue;
  1437.     }
  1438.     #[Route(path'/admin/events/{id}/manual-attendance'name'app_admin_event_manual_attendance'methods: ['POST'])]
  1439.     public function manualAttendance(Request $requestint $idRegistrationsRepository $registrationsRepository): Response
  1440.     {
  1441.         $event $this->eventsRepository->find($id);
  1442.         if (!$event) {
  1443.             $this->addFlash('error''Evento no encontrado.');
  1444.             return $this->redirectToRoute('app_events');
  1445.         }
  1446.         if (!$this->RolesController->checkAccessToEvent($event) || !$this->RolesController->hasCapability('CREATE_ATTENDANCES')) {
  1447.             $this->addFlash('error''No tienes permisos para acceder a este evento');
  1448.             return $this->redirectToRoute('app_events');
  1449.         }
  1450.         if ($event->getStatus() !== EventStatus::EN_CURSO) {
  1451.             $this->addFlash('error''Solo se pueden registrar asistencias cuando el curso está en estado En curso.');
  1452.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1453.         }
  1454.         $registrationId $request->request->get('registration_id');
  1455.         $date $request->request->get('attendance_date');
  1456.         $time $request->request->get('attendance_time');
  1457.         if (!$registrationId || !$date || !$time) {
  1458.             $this->addFlash('error''Faltan datos obligatorios para registrar la asistencia.');
  1459.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1460.         }
  1461.         $registration $registrationsRepository->find($registrationId);
  1462.         if (!$registration || $registration->getEventId()->getId() !== $event->getId()) {
  1463.             $this->addFlash('error''Inscripción no válida o no pertenece a este evento.');
  1464.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1465.         }
  1466.         try {
  1467.             $checkinTime = new \DateTime($date ' ' $time);
  1468.         } catch (\Exception $e) {
  1469.             $this->addFlash('error''Formato de fecha u hora no válido.');
  1470.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1471.         }
  1472.         // Validate date is within event range
  1473.         if (!$this->utilsController->isDateBetween($checkinTime$event->getStartDate(), $event->getEndDate())) {
  1474.             $this->addFlash('error''La fecha de fichaje no está comprendida entre las fechas del evento.');
  1475.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1476.         }
  1477.         $attendance = new Attendance();
  1478.         $attendance->setEventId($event);
  1479.         $attendance->setRegistration($registration);
  1480.         $attendance->setCheckinTime($checkinTime);
  1481.         $attendance->setCreatorUserId($this->getUser());
  1482.         $attendance->setMethod("WEB_ADMIN");
  1483.         $attendance->setDni($registration->getUserId()->getDni());
  1484.         $attendance->setQrCode($registration->getManualCode());
  1485.         $attendance->setCreated(new \DateTime());
  1486.         $attendance->setUpdated(new \DateTime());
  1487.         $registration->setLastScanned(new \DateTime());
  1488.         $this->entityManager->persist($attendance);
  1489.         $this->entityManager->persist($registration);
  1490.         $this->entityManager->flush();
  1491.         $this->addFlash('success''Asistencia manual registrada correctamente.');
  1492.         return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1493.     }
  1494.     #[Route(path'/admin/events/{id}/import-attendance-csv'name'app_admin_event_import_attendance_csv'methods: ['POST'])]
  1495.     public function importAttendanceCsv(Request $requestint $idRegistrationsRepository $registrationsRepository): Response
  1496.     {
  1497.         $event $this->eventsRepository->find($id);
  1498.         if (!$event) {
  1499.             $this->addFlash('error''Evento no encontrado.');
  1500.             return $this->redirectToRoute('app_events');
  1501.         }
  1502.         if (!$this->RolesController->checkAccessToEvent($event) || !$this->RolesController->hasCapability('CREATE_ATTENDANCES')) {
  1503.             $this->addFlash('error''No tienes permisos para acceder a este evento');
  1504.             return $this->redirectToRoute('app_events');
  1505.         }
  1506.         if ($event->getStatus() !== EventStatus::EN_CURSO) {
  1507.             $this->addFlash('error''Solo se pueden registrar asistencias cuando el curso está en estado En curso.');
  1508.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1509.         }
  1510.         $file $request->files->get('csv_file');
  1511.         if (!$file) {
  1512.             $this->addFlash('error''No se ha proporcionado ningún archivo CSV.');
  1513.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1514.         }
  1515.         if ($file->getClientOriginalExtension() !== 'csv') {
  1516.             $this->addFlash('error''El archivo debe tener formato CSV.');
  1517.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1518.         }
  1519.         $successCount 0;
  1520.         $errorCount 0;
  1521.         $errors = [];
  1522.         if (($handle fopen($file->getPathname(), "r")) !== false) {
  1523.             $rowNum 0;
  1524.             $isFirstRow true;
  1525.             while (($data fgetcsv($handle1000";"'"''\\')) !== false) {
  1526.                 $rowNum++;
  1527.                 
  1528.                 // Strip UTF-8 BOM from first field of first row
  1529.                 if ($isFirstRow && isset($data[0])) {
  1530.                     $data[0] = preg_replace('/^\xEF\xBB\xBF/'''$data[0]);
  1531.                     $isFirstRow false;
  1532.                 }
  1533.                 
  1534.                 // Skip header or badly formatted lines
  1535.                 if (count($data) < 4) {
  1536.                     $errors[] = "Fila $rowNum: Formato incorrecto, se esperan 4 columnas.";
  1537.                     $errorCount++;
  1538.                     continue;
  1539.                 }
  1540.                 
  1541.                 $csvEventExtCode trim($data[0]);
  1542.                 $csvDni trim($data[1]);
  1543.                 $csvDate trim($data[2]); // dd-mm-yyyy
  1544.                 $csvTime trim($data[3]); // hh:mm
  1545.                 // Normalize unicode dashes (en-dash –, em-dash —) to regular hyphen
  1546.                 $csvEventExtCode str_replace(["\u{2013}""\u{2014}"], '-'$csvEventExtCode);
  1547.                 $csvDate str_replace(["\u{2013}""\u{2014}"], '-'$csvDate);
  1548.                 // Skip header string
  1549.                 if (strtolower($csvDni) === 'dni') {
  1550.                     continue; 
  1551.                 }
  1552.                 if ((string) $csvEventExtCode !== (string) $event->getExtCode()) {
  1553.                     $errors[] = "Fila $rowNum: El código de evento ($csvEventExtCode) no coincide con este evento (" $event->getExtCode() . ").";
  1554.                     $errorCount++;
  1555.                     continue;
  1556.                 }
  1557.                 $registration $registrationsRepository->findOneBy(['eventId' => $event'dni' => $csvDni'active' => true]);
  1558.                 if (!$registration) {
  1559.                     $errors[] = "Fila $rowNum: No se encontró la inscripción para el DNI ($csvDni).";
  1560.                     $errorCount++;
  1561.                     continue;
  1562.                 }
  1563.                 try {
  1564.                     $checkinTime \DateTime::createFromFormat('d-m-Y H:i'$csvDate ' ' $csvTime);
  1565.                     if (!$checkinTime) {
  1566.                         throw new \Exception("Invalid date format");
  1567.                     }
  1568.                 } catch (\Exception $e) {
  1569.                     $errors[] = "Fila $rowNum: Formato de fecha ($csvDate $csvTime) no válido (esperado dd-mm-yyyy hh:mm).";
  1570.                     $errorCount++;
  1571.                     continue;
  1572.                 }
  1573.                 if (!$this->utilsController->isDateBetween($checkinTime$event->getStartDate(), $event->getEndDate())) {
  1574.                     $errors[] = "Fila $rowNum: La fecha de fichaje no está comprendida entre las fechas del evento.";
  1575.                     $errorCount++;
  1576.                     continue;
  1577.                 }
  1578.                 $attendance = new Attendance();
  1579.                 $attendance->setEventId($event);
  1580.                 $attendance->setRegistration($registration);
  1581.                 $attendance->setCheckinTime($checkinTime);
  1582.                 $attendance->setCreatorUserId($this->getUser());
  1583.                 $attendance->setMethod("IMPORT_CSV");
  1584.                 $attendance->setDni($csvDni);
  1585.                 $attendance->setQrCode($registration->getManualCode());
  1586.                 $attendance->setCreated(new \DateTime());
  1587.                 $attendance->setUpdated(new \DateTime());
  1588.                 $registration->setLastScanned(new \DateTime());
  1589.                 $this->entityManager->persist($attendance);
  1590.                 $this->entityManager->persist($registration);
  1591.                 
  1592.                 $successCount++;
  1593.             }
  1594.             fclose($handle);
  1595.             
  1596.             if ($successCount 0) {
  1597.                 $this->entityManager->flush();
  1598.             }
  1599.         } else {
  1600.             $this->addFlash('error''Error al leer el archivo CSV.');
  1601.             return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);
  1602.         }
  1603.         if ($successCount 0) {
  1604.             $this->addFlash('success'"Se han importado $successCount asistencias correctamente.");
  1605.         }
  1606.         
  1607.         if ($errorCount 0) {
  1608.             $this->addFlash('error'"Se encontraron $errorCount errores en el archivo. Verifica el formato.");
  1609.         }
  1610.         return $this->redirectToRoute('app_admin_view_event', ['id' => $id'main_tab' => 'asistencias']);
  1611.     }
  1612.     private function applyAutomaticEventOwnership(Events $event): void
  1613.     {
  1614.         $user $this->getUser();
  1615.         if (!$user instanceof User) {
  1616.             return;
  1617.         }
  1618.         if (in_array('ROLE_MANAGER'$user->getRoles(), true)) {
  1619.             $event->addManagerUser($user);
  1620.         }
  1621.         if ($event->getCenter() !== null) {
  1622.             return;
  1623.         }
  1624.         $defaultCenter $this->roleAccessService->getDefaultEventCenter($user);
  1625.         if ($defaultCenter !== null) {
  1626.             $event->setCenter($defaultCenter);
  1627.         }
  1628.     }
  1629.     private function ensureEventCenterIsResolved(Events $eventFormInterface $form): bool
  1630.     {
  1631.         if ($event->getCenter() !== null) {
  1632.             return true;
  1633.         }
  1634.         $user $this->getUser();
  1635.         if (!$user instanceof User) {
  1636.             return true;
  1637.         }
  1638.         $role $this->roleAccessService->getPrimaryRole($user);
  1639.         if (!in_array($role, ['ROLE_CENTER''ROLE_MANAGER'], true)) {
  1640.             return true;
  1641.         }
  1642.         $form->get('center')->addError(new FormError('No se ha podido determinar el centro del curso. Revisa la configuración del usuario o selecciona un centro válido.'));
  1643.         return false;
  1644.     }
  1645.     private function ensureEventAssignmentsAreValid(Events $eventFormInterface $form): bool
  1646.     {
  1647.         $isValid true;
  1648.         $center $event->getCenter();
  1649.         if ($center === null) {
  1650.             if (!$event->getManagerUsers()->isEmpty()) {
  1651.                 $form->get('managerUsers')->addError(new FormError('Selecciona un centro para poder asignar responsables de gestión.'));
  1652.                 $isValid false;
  1653.             }
  1654.             if (!$event->getCourseUser()->isEmpty()) {
  1655.                 $form->get('courseUser')->addError(new FormError('Selecciona un centro para poder asignar docentes.'));
  1656.                 $isValid false;
  1657.             }
  1658.             return $isValid;
  1659.         }
  1660.         $centerId $center->getId();
  1661.         foreach ($event->getManagerUsers() as $managerUser) {
  1662.             if (!in_array('ROLE_MANAGER'$managerUser->getRoles(), true)) {
  1663.                 $form->get('managerUsers')->addError(new FormError('Todos los responsables de gestión deben tener el rol de gestor.'));
  1664.                 $isValid false;
  1665.                 break;
  1666.             }
  1667.             if ($managerUser->getManagementCenter()?->getId() !== $centerId) {
  1668.                 $form->get('managerUsers')->addError(new FormError('Todos los responsables de gestión deben pertenecer al centro seleccionado.'));
  1669.                 $isValid false;
  1670.                 break;
  1671.             }
  1672.         }
  1673.         foreach ($event->getCourseUser() as $courseUser) {
  1674.             if (!in_array('ROLE_COURSE'$courseUser->getRoles(), true)) {
  1675.                 $form->get('courseUser')->addError(new FormError('Todos los docentes seleccionados deben tener el rol docente.'));
  1676.                 $isValid false;
  1677.                 break;
  1678.             }
  1679.             if ($courseUser->getManagementCenter()?->getId() !== $centerId) {
  1680.                 $form->get('courseUser')->addError(new FormError('Todos los docentes deben pertenecer al centro seleccionado.'));
  1681.                 $isValid false;
  1682.                 break;
  1683.             }
  1684.         }
  1685.         return $isValid;
  1686.     }
  1687. }