src/Controller/EventsController.php line 95
<?phpnamespace App\Controller;use App\Entity\Events;use App\Entity\Attendance;use App\Form\EventsFormType;use App\Repository\AttendanceRepository;use App\Repository\RegistrationsRepository;use App\Repository\HomeSliderRepository;use App\Repository\RoleRepository;use App\Repository\EventsRepository;use Symfony\Component\HttpFoundation\Request;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\BinaryFileResponse;use Symfony\Component\Routing\Annotation\Route;use Symfony\Contracts\Translation\TranslatorInterface;use Doctrine\ORM\EntityManagerInterface;use App\Controller\RolesController;use App\Controller\NotificationsController;use App\Repository\ParamsRepository;use Symfony\Component\HttpFoundation\RequestStack;use Symfony\Component\Messenger\MessageBusInterface;use App\Message\SendEmailMessage;use Knp\Component\Pager\PaginatorInterface;use App\Repository\TaxonomyValueRepository;use Dompdf\Dompdf;use Dompdf\Options;use App\Entity\EventRequest;use Symfony\Component\HttpKernel\KernelInterface;use App\Entity\Registrations;use App\Controller\UtilsController;use App\Enum\EventStatus;use App\Repository\EventRequestRepository;use App\Entity\RegistrationQualification;use App\Entity\User;use App\Repository\RegistrationQualificationRepository;use App\Repository\RegistrationCertificateRepository;use App\Repository\SurveyCampaignRepository;use App\Repository\UserPenaltyRepository;use App\Services\CertificateGenerationService;use App\Services\EventQualificationService;use App\Services\EventInscriptionEndDateAdjustmentService;use App\Services\QualificationCsvService;use App\Services\RoleAccessService;use App\Services\SurveyLifecycleService;use App\Services\TeacherHourWarningService;use Symfony\Component\Form\FormError;use Symfony\Component\Form\FormInterface;use PhpOffice\PhpSpreadsheet\Spreadsheet;use PhpOffice\PhpSpreadsheet\Writer\Xlsx;class EventsController extends AbstractController{private const QUALIFICATION_TYPE_ROUTE_REQUIREMENT = 'ASISTENCIAS|CUESTIONARIO|PRACTICAS|CALIFICACION_FINAL';private const FINAL_QUALIFICATIONS_DOWNLOAD_BLOCKED_MESSAGE = 'La descarga estará disponible cuando todas las calificaciones se hayan cumplimentado.';private $RolesController;private $NotificationsController;private MessageBusInterface $bus;public function __construct(private EventsRepository $eventsRepository,private TranslatorInterface $translator ,private RoleRepository $roleRepository,private EntityManagerInterface $entityManager,private AttendanceRepository $attendanceRepository,private ParamsRepository $paramsRepository,private RegistrationQualificationRepository $registrationQualificationRepository,private RegistrationCertificateRepository $registrationCertificateRepository,private SurveyCampaignRepository $surveyCampaignRepository,private UserPenaltyRepository $userPenaltyRepository,private CertificateGenerationService $certificateGenerationService,private EventQualificationService $eventQualificationService,private EventInscriptionEndDateAdjustmentService $eventInscriptionEndDateAdjustmentService,private QualificationCsvService $qualificationCsvService,private RoleAccessService $roleAccessService,private SurveyLifecycleService $surveyLifecycleService,private TeacherHourWarningService $teacherHourWarningService,RolesController $rolesController,NotificationsController $notificationsController,private RequestStack $requestStack,MessageBusInterface $bus,private UtilsController $utilsController,){$this->RolesController = $rolesController;$this->NotificationsController = $notificationsController;$this->bus = $bus;}#[Route('/admin/events', name: 'app_events')]public function index(): Response{return $this->render('admin/events/events.html.twig', ['eventsDataUrl' => $this->generateUrl('app_admin_events_data'),'eventsExportUrl' => $this->generateUrl('app_admin_events_export'),]);}#[Route('/admin/events/data', name: 'app_admin_events_data', methods: ['GET'])]public function eventsData(Request $request): JsonResponse{$query = $request->query->all();$draw = max(0, (int) ($query['draw'] ?? 0));$offset = max(0, (int) ($query['start'] ?? 0));$limit = max(1, min(100, (int) ($query['length'] ?? 10)));$search = trim((string) (($query['search']['value'] ?? '') ?: ''));[$orderColumn, $orderDirection] = $this->resolveEventDataTableOrdering($request);$eventIds = $this->getVisibleEventIds();$recordsTotal = $this->eventsRepository->countVisibleForIds($eventIds);$recordsFiltered = $search === ''? $recordsTotal: $this->eventsRepository->countVisibleForIds($eventIds, $search);$rows = $this->eventsRepository->findVisiblePageForIds($eventIds,$search,$orderColumn,$orderDirection,$offset,$limit);$actionVisibility = $this->buildEventActionVisibility();$data = array_map(fn (array $row): array => $this->buildEventTableRow($row, $actionVisibility),$rows);return new JsonResponse(['draw' => $draw,'recordsTotal' => $recordsTotal,'recordsFiltered' => $recordsFiltered,'data' => $data,]);}#[Route('/admin/events/teacher-hour-warnings', name: 'app_admin_event_teacher_hour_warnings', methods: ['GET'])]public function teacherHourWarnings(Request $request): JsonResponse{$eventId = max(0, (int) $request->query->get('eventId', 0));if ($eventId > 0) {$event = $this->eventsRepository->find($eventId);if (!$event instanceof Events) {return new JsonResponse(['message' => 'Evento no encontrado.'], Response::HTTP_NOT_FOUND);}if (!$this->RolesController->canEditEvent($event)) {return new JsonResponse(['message' => 'No tienes permisos para editar este evento.'], Response::HTTP_FORBIDDEN);}} elseif (!$this->RolesController->hasCapability('CREATE_EVENTS')) {return new JsonResponse(['message' => 'No tienes permisos para crear eventos.'], Response::HTTP_FORBIDDEN);}$teacherIds = array_values(array_filter(array_map(static fn (mixed $teacherId): int => (int) $teacherId,(array) ($request->query->all()['teacherIds'] ?? []))));$year = $this->resolveTeacherWarningYear($request->query->get('year'));$hours = $request->query->get('hours');$eventHours = is_numeric((string) $hours) ? max(0, (int) $hours) : 0;if ($teacherIds === [] || $year === null) {return new JsonResponse(['warnings' => []]);}return new JsonResponse(['warnings' => $this->teacherHourWarningService->buildWarnings($teacherIds,$year,$eventId > 0 ? $eventId : null,$eventHours),]);}#[Route('/admin/events/export', name: 'app_admin_events_export', methods: ['GET'])]public function eventsExport(Request $request): Response{if (!$this->RolesController->hasCapability('VIEW_EVENTS')) {$this->addFlash('error', 'No tienes permisos para exportar cursos');return $this->redirectToRoute('app_events');}$search = trim((string) $request->query->get('search', ''));[$orderColumn, $orderDirection] = $this->resolveEventExportOrdering($request);$eventIds = $this->getVisibleEventIds();$rows = $this->eventsRepository->findVisibleExportRowsForIds($eventIds,$search,$orderColumn,$orderDirection);$spreadsheet = new Spreadsheet();$sheet = $spreadsheet->getActiveSheet();$sheet->fromArray(['Publicado','Estado','Código','Nombre','Inicio','Fin','Solicitudes','Inscripciones','Ocupación',], null, 'A1');$rowNumber = 2;foreach ($rows as $row) {$registrationMetrics = $this->buildEventRegistrationMetrics($row);$statusValue = $row['status'] ?? '';if ($statusValue instanceof EventStatus) {$statusValue = $statusValue->value;}$sheet->fromArray([(bool) ($row['published'] ?? false) ? 'Sí' : 'No',$this->formatEventStatusLabel((string) $statusValue),(string) ($row['extCode'] ?? ''),(string) ($row['name'] ?? ''),$this->formatEventDate($row['startDate'] ?? null),$this->formatEventDate($row['endDate'] ?? null),max(0, (int) ($row['requestCount'] ?? 0)),$registrationMetrics['registrationSummary'],$registrationMetrics['registrationPercentLabel'],], null, 'A'.$rowNumber);$rowNumber++;}foreach ($sheet->getColumnIterator() as $column) {$sheet->getColumnDimension($column->getColumnIndex())->setAutoSize(true);}$sheet->getStyle('A1:I1')->getFont()->setBold(true);$tmpFile = tempnam(sys_get_temp_dir(), 'events_export_');if ($tmpFile === false) {throw new \RuntimeException('No se pudo crear el fichero temporal para la exportación.');}$writer = new Xlsx($spreadsheet);$writer->save($tmpFile);$fileName = (new \DateTimeImmutable())->format('Ymd').'-cursos.xlsx';$response = new BinaryFileResponse($tmpFile, 200, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',]);$response->setContentDisposition('attachment', $fileName);$response->deleteFileAfterSend(true);return $response;}#[Route(path: '/admin/events/new', name: 'app_admin_new_event')]public function newEvent(Request $request){if (!$this->RolesController->hasCapability('CREATE_EVENTS')) {$this->addFlash('error', 'No tienes permisos para crear eventos');return $this->redirectToRoute('app_events');}$form = $this->createForm(EventsFormType::class);$form->handleRequest($request);if ($form->isSubmitted() && $form->isValid()) {// /** @var Events $Event */$event = $form->getData();$imageFile = $form->get('imageFile')->getData();if ($imageFile) {$newFilename = uniqid().'.'.$imageFile->guessExtension();$imageFile->move($this->getParameter('kernel.project_dir').'/public/images', $newFilename);$event->setImage($newFilename);}if ($request->request->get('remove_image')) {$event->setImage(null);}if($event->getNumberCheckings() == null){ //Si el número necesario de fichajes está vacío lo calculamosif($event->getShift() == "" || $event->getShift() == null || $event->getShift() == "1") {$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.}else{$numberCheckings = $event->getTotalDays() * 2;}$event->setNumberCheckings($numberCheckings);}$event->setCreated((new \DateTime()));$event->setUpdated((new \DateTime()));$event->setStatus(EventStatus::ABIERTO);$this->applyAutomaticEventOwnership($event);if (!$this->ensureEventCenterIsResolved($event, $form)) {return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}if (!$this->ensureEventAssignmentsAreValid($event, $form)) {return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}$this->entityManager->persist($event);$this->entityManager->flush();$this->addFlash('success', 'Evento guardado');$this->addTeacherHourWarningFlashes($event);return $this->redirectToRoute('app_events');}return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}#[Route(path: '/admin/events/view/{id}', name: 'app_admin_view_event')]public function viewEvent(Request $request, $id){if (!$this->RolesController->hasCapability('VIEW_EVENTS')) {$this->addFlash('error', 'No tienes permisos para ver este evento');return $this->redirectToRoute('app_events');}$event = $this->eventsRepository->find($id);//Comprobamos que el usuario tiene asociado de alguna manera el evento y puede acceder al él.$allow = $this->RolesController->checkAccessToEvent($event);if($allow == false){$this->addFlash('error', 'No tienes permisos para ver este evento');return $this->redirectToRoute('app_events');}$eventRegistrations = $event->getActiveRegistrations();$numRegistrations = $eventRegistrations->count();$eventAttendances = $this->attendanceRepository->findBy(['eventId' => $id]);$eventManagerUsers = $event->getManagerUsers();$eventCourseUsers = $event->getCourseUser();$eventRequests = $event->getEventRequests();$numRequests = $eventRequests->count();$qualificationTypesConfig = $this->getQualificationTypesConfig($event);$finalQualificationType = RegistrationQualification::TIPO_CALIFICACION_FINAL;$enabledQualificationTypes = array_keys(array_filter($qualificationTypesConfig,static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false) && !($cfg['final_tab_only'] ?? false)));$enabledQualificationTypesForView = array_keys(array_filter($qualificationTypesConfig,static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false)));$existingQualifications = $this->registrationQualificationRepository->findByEventAndTypes($event,$enabledQualificationTypesForView);$qualificationsByType = [];foreach ($existingQualifications as $qualification) {$registration = $qualification->getRegistration();if ($registration === null) {continue;}$qualificationsByType[$qualification->getTipoCalificacion()][$registration->getId()] = $qualification;}$finalGradeTabEnabled = (bool) ($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false);$allowedQualificationTabs = $enabledQualificationTypes;if ($finalGradeTabEnabled) {$allowedQualificationTabs[] = $finalQualificationType;}$activeQualificationTab = (string) $request->query->get('qualification_tab', '');if (!in_array($activeQualificationTab, $allowedQualificationTabs, true)) {$activeQualificationTab = $enabledQualificationTypes[0] ?? ($finalGradeTabEnabled ? $finalQualificationType : '');}//Cálculo de cumplimiento global del curso$eventPercentCompliance = $this->RolesController->getComplianceByEvent($event);$qualificationRows = $this->eventQualificationService->buildQualificationRows($eventRegistrations,$qualificationsByType,$enabledQualificationTypes,$finalQualificationType,(float) ($event->getMinCompliance() ?? 0.0));$activePenaltyByRegistration = [];$registrationUsers = [];foreach ($eventRegistrations as $registration) {$user = $registration->getUserId();if ($user !== null) {$registrationUsers[] = $user;}}$activePenaltiesByUserId = $this->userPenaltyRepository->findActiveForUsersAndDate($registrationUsers, new \DateTime('today'));foreach ($eventRegistrations as $registration) {$registrationId = $registration->getId();$userId = $registration->getUserId()?->getId();if ($registrationId === null || $userId === null || !isset($activePenaltiesByUserId[$userId])) {continue;}$activePenaltyByRegistration[$registrationId] = $activePenaltiesByUserId[$userId];}$downloadAvailability = $this->eventQualificationService->getDownloadAvailability($event);$surveyCampaign = $this->surveyCampaignRepository->findOneBy(['event' => $event]);if ($surveyCampaign instanceof \App\Entity\SurveyCampaign && $this->surveyLifecycleService->refreshCampaignState($surveyCampaign)) {$this->entityManager->flush();}$courseUserHourWarnings = $this->buildTeacherHourWarningsForEvent($event);$forceStatusInscriptionEndAdjustment = $this->eventInscriptionEndDateAdjustmentService->buildAdjustmentContext($event,EventStatus::INSCRIPCIONES_CERRADAS);return $this->render('admin/events/eventview.html.twig', ['event' => $event,'eventRegistrations' => $eventRegistrations,'numRegistrations' => $numRegistrations,'Attendances' => $eventAttendances,'managerUsers' => $eventManagerUsers,'courseUsers' => $eventCourseUsers,'permissions' => $this->RolesController->getPermissionsByRol(),'compliance' => $eventPercentCompliance,'eventRequests' => $eventRequests,'numRequests' => $numRequests,'minCompliance' => $event->getMinCompliance(),'qualificationTypesConfig' => $qualificationTypesConfig,'enabledQualificationTypes' => $enabledQualificationTypes,'qualificationsByType' => $qualificationsByType,'qualificationRows' => $qualificationRows,'activeQualificationTab' => $activeQualificationTab,'finalQualificationType' => $finalQualificationType,'finalGradeTabEnabled' => $finalGradeTabEnabled,'penaltyReasonOptions' => $this->eventQualificationService->getPenaltyReasonOptions(),'penaltyReasonMetadata' => $this->eventQualificationService->getPenaltyReasonMetadata(),'penaltyReasonLabels' => $this->eventQualificationService->getAllPenaltyReasonLabels(),'activePenaltyByRegistration' => $activePenaltyByRegistration,'downloadAvailability' => $downloadAvailability,'surveyCampaign' => $surveyCampaign,'surveySendAvailability' => $surveyCampaign instanceof \App\Entity\SurveyCampaign? $this->surveyLifecycleService->getSendAvailability($surveyCampaign): null,'courseUserHourWarnings' => $courseUserHourWarnings,'forceStatusInscriptionEndAdjustment' => $forceStatusInscriptionEndAdjustment,'certificateGenerationStatus' => $this->certificateGenerationService->getEventGenerationStatus($event),'activeCertificatesByRegistration' => $this->registrationCertificateRepository->findActiveByEventIndexed($event),]);}#[Route(path: '/admin/events/{id}/qualifications/{type}',name: 'app_admin_event_save_qualifications',methods: ['POST'],requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT])]public function saveQualifications(Events $event, string $type, Request $request): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {$this->addFlash('error', 'El tipo de calificación no está habilitado para este evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}if (($qualificationTypesConfig[$type]['editable'] ?? false) === false) {$this->addFlash('error', 'Este tipo de calificación se calcula automáticamente.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'qualification_tab' => $type,]);}$token = (string) $request->request->get('_token');if (!$this->isCsrfTokenValid('save_qualifications_'.$event->getId().'_'.$type, $token)) {$this->addFlash('error', 'Token CSRF inválido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'qualification_tab' => $type,]);}$submittedQualifications = $request->request->all('qualifications');try {$result = $this->eventQualificationService->saveQualificationsForType($event, $type, $submittedQualifications, $this->getUser());$this->addFlash('success', 'Calificaciones guardadas correctamente.');$this->addQualificationWarnings($result['warnings'] ?? []);} catch (\InvalidArgumentException $exception) {$this->addFlash('error', $exception->getMessage());}return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}#[Route(path: '/admin/events/{id}/qualifications/asistencias/propose', name: 'app_admin_event_propose_attendance_qualifications', methods: ['POST'])]public function proposeAttendanceQualifications(Events $event, Request $request): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!($qualificationTypesConfig[RegistrationQualification::TIPO_ASISTENCIAS]['enabled'] ?? false)) {$this->addFlash('error', 'El control de asistencias no está habilitado para este evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$token = (string) $request->request->get('_token');if (!$this->isCsrfTokenValid('propose_attendance_qualifications_'.$event->getId(), $token)) {$this->addFlash('error', 'Token CSRF inválido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => RegistrationQualification::TIPO_ASISTENCIAS,]);}$updatedCount = $this->eventQualificationService->proposeAttendanceQualifications($event, $this->getUser());if ($updatedCount > 0) {$this->addFlash('success', sprintf('Se han propuesto %d calificaciones de asistencias.', $updatedCount));} else {$this->addFlash('info', 'No había calificaciones de asistencias pendientes de proponer.');}return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => RegistrationQualification::TIPO_ASISTENCIAS,]);}#[Route(path: '/admin/events/{id}/qualifications/final/propose', name: 'app_admin_event_propose_final_qualifications', methods: ['POST'])]public function proposeFinalQualifications(Events $event, Request $request): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$finalQualificationType = RegistrationQualification::TIPO_CALIFICACION_FINAL;$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false)) {$this->addFlash('error', 'La calificación final solo está disponible en cursos finalizados.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$token = (string) $request->request->get('_token');if (!$this->isCsrfTokenValid('propose_final_qualifications_'.$event->getId(), $token)) {$this->addFlash('error', 'Token CSRF inválido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $finalQualificationType,]);}$updatedCount = $this->eventQualificationService->proposeFinalQualifications($event, $this->getUser());if ($updatedCount > 0) {$this->addFlash('success', sprintf('Se han propuesto %d calificaciones finales.', $updatedCount));} else {$this->addFlash('info', 'No había calificaciones finales pendientes de proponer.');}return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $finalQualificationType,]);}#[Route(path: '/admin/events/{id}/qualifications/{type}/template.csv',name: 'app_admin_event_qualification_template_csv',methods: ['GET'],requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT])]public function downloadQualificationTemplateCsv(Events $event, string $type): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {$this->addFlash('error', 'El tipo de calificación no está habilitado para este evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$content = $this->qualificationCsvService->buildTemplateContent($event, $type);$rawCode = (string) ($event->getExtCode() ?? 'evento');$safeExtCode = preg_replace('/[^A-Za-z0-9_\-]/', '_', $rawCode);$filename = sprintf('%s-%s-plantilla-%s.csv', date('Ymd'), $safeExtCode, strtolower($type));$response = new Response($content);$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');$response->headers->set('Content-Disposition', 'attachment; filename="'.$filename.'"');return $response;}#[Route(path: '/admin/events/{id}/qualifications/{type}/import.csv',name: 'app_admin_event_import_qualifications_csv',methods: ['POST'],requirements: ['type' => self::QUALIFICATION_TYPE_ROUTE_REQUIREMENT])]public function importQualificationsCsv(Events $event, string $type, Request $request): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!isset($qualificationTypesConfig[$type]) || !($qualificationTypesConfig[$type]['enabled'] ?? false)) {$this->addFlash('error', 'El tipo de calificación no está habilitado para este evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$token = (string) $request->request->get('_token');if (!$this->isCsrfTokenValid('import_qualifications_csv_'.$event->getId().'_'.$type, $token)) {$this->addFlash('error', 'Token CSRF inválido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}$file = $request->files->get('csv_file');if (!$file) {$this->addFlash('error', 'No se ha proporcionado ningún archivo CSV.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}if (strtolower((string) $file->getClientOriginalExtension()) !== 'csv') {$this->addFlash('error', 'El archivo debe tener formato CSV.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}try {$result = $this->qualificationCsvService->importQualificationsFromCsv($event, $type, $file, $this->getUser());} catch (\Throwable $exception) {$this->addFlash('error', $exception->getMessage());return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}if (!empty($result['errors'])) {$this->addFlash('error', sprintf('Se han detectado %d errores en el CSV. No se ha importado ningún cambio.', count($result['errors'])));foreach (array_slice($result['errors'], 0, 5) as $error) {$this->addFlash('error', $error);}} elseif ($result['updated_count'] > 0) {$this->addFlash('success', sprintf('Se han importado %d calificaciones correctamente.', $result['updated_count']));} else {$this->addFlash('info', 'El fichero no contenía cambios para importar.');}if (empty($result['errors'])) {$this->addQualificationWarnings($result['warnings'] ?? []);}return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $type,]);}#[Route(path: '/admin/events/{id}/qualifications/final-grid', name: 'app_admin_event_save_final_qualifications', methods: ['POST'])]public function saveFinalQualifications(Events $event, Request $request): Response{if (!$this->RolesController->canEditQualifications($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$finalQualificationType = RegistrationQualification::TIPO_CALIFICACION_FINAL;$qualificationTypesConfig = $this->getQualificationTypesConfig($event);if (!($qualificationTypesConfig[$finalQualificationType]['enabled'] ?? false)) {$this->addFlash('error', 'La calificación final solo está disponible en cursos finalizados.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$token = (string) $request->request->get('_token');if (!$this->isCsrfTokenValid('save_final_qualifications_'.$event->getId(), $token)) {$this->addFlash('error', 'Token CSRF inválido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $finalQualificationType,]);}$editableTypes = array_keys(array_filter($qualificationTypesConfig,static fn (array $cfg): bool => (bool) ($cfg['enabled'] ?? false) && (bool) ($cfg['editable'] ?? false)));$submittedQualifications = $request->request->all('final_grid');try {$result = $this->eventQualificationService->saveFinalGrid($event, $submittedQualifications, $editableTypes, $this->getUser());$this->addFlash('success', 'Calificaciones finales guardadas correctamente.');$this->addQualificationWarnings($result['warnings'] ?? []);} catch (\InvalidArgumentException $exception) {$this->addFlash('error', $exception->getMessage());}return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId(),'main_tab' => 'calificaciones','qualification_tab' => $finalQualificationType,]);}#[Route(path: '/admin/events/{id}/final-qualifications.xlsx', name: 'app_admin_event_export_final_qualifications', methods: ['GET'])]public function exportFinalQualifications(Events $event): Response{if (!$this->RolesController->canExportReports($event)) {$this->addFlash('error', 'No tienes permisos para acceder a este evento');return $this->redirectToRoute('app_events');}$downloadAvailability = $this->eventQualificationService->getDownloadAvailability($event);if (!$downloadAvailability['available']) {$this->addFlash('error',$downloadAvailability['reason_code'] === 'event_not_finalized'? 'Solo se puede descargar este informe cuando el curso está finalizado.': self::FINAL_QUALIFICATIONS_DOWNLOAD_BLOCKED_MESSAGE);return $this->redirectToRoute('app_admin_view_event', ['id' => $event->getId()]);}$registrations = $event->getActiveRegistrations();$qualificationTypes = [RegistrationQualification::TIPO_ASISTENCIAS,RegistrationQualification::TIPO_CUESTIONARIO,RegistrationQualification::TIPO_PRACTICAS,RegistrationQualification::TIPO_CALIFICACION_FINAL,];$existingQualifications = $this->registrationQualificationRepository->findByEventAndTypes($event, $qualificationTypes);$qualificationsMap = [];foreach ($existingQualifications as $qualification) {$registration = $qualification->getRegistration();if ($registration === null || $registration->getId() === null) {continue;}$qualificationsMap[$registration->getId()][$qualification->getTipoCalificacion()] = ['value' => $qualification->getCalificacion(),'comment' => $qualification->getObservaciones(),'penalty_reason_type' => $qualification->getPenaltyReason()?->getCode() ?? $qualification->getMotivoPenalizacionTipo(),];}$penaltyReasonOptions = $this->eventQualificationService->getAllPenaltyReasonLabels();$spreadsheet = new Spreadsheet();$sheet = $spreadsheet->getActiveSheet();$headers = ['Código Curso EXT','Nombre Curso','Fecha Inicio Curso','Fecha Fin Curso','Número Horas','DNI Alumno','Nombre','Apellidos','Calificación Asistencia','Calificación Cuestionario','Calificación Prácticas','Calificación Final','Observaciones Finales','Motivo Penalización Final',];$sheet->fromArray($headers, null, 'A1');$row = 2;foreach ($registrations as $registration) {$user = $registration->getUserId();$profile = $user?->getProfile();$name = $profile?->getNombre() ?? $user?->getNomComplet();$surname = $user?->getSurname();if ($profile && $profile->getApellido1() !== '') {$surname = $profile->getApellido1();if ($profile->getApellido2() !== '') {$surname .= ' '.$profile->getApellido2();}}$registrationId = $registration->getId();$asistencias = $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_ASISTENCIAS]['value'] ?? '') : '';$cuestionario = $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_CUESTIONARIO]['value'] ?? '') : '';$practicas = $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_PRACTICAS]['value'] ?? '') : '';$finalQualificationData = $registrationId !== null ? ($qualificationsMap[$registrationId][RegistrationQualification::TIPO_CALIFICACION_FINAL] ?? null) : null;$final = $finalQualificationData['value'] ?? '';$finalComment = $finalQualificationData['comment'] ?? '';$finalPenaltyReason = '';if (!empty($finalQualificationData['penalty_reason_type'])) {$finalPenaltyReason = $penaltyReasonOptions[$finalQualificationData['penalty_reason_type']] ?? (string) $finalQualificationData['penalty_reason_type'];}$sheet->fromArray([(string) ($event->getExtCode() ?? ''),(string) ($event->getName() ?? ''),$event->getStartDate()?->format('d-m-Y') ?? '',$event->getEndDate()?->format('d-m-Y') ?? '',(string) ($event->getHours() ?? ''),(string) ($registration->getDni() ?? ''),(string) ($name ?? ''),(string) ($surname ?? ''),(string) ($asistencias ?? ''),(string) ($cuestionario ?? ''),(string) ($practicas ?? ''),(string) ($final ?? ''),(string) ($finalComment ?? ''),(string) ($finalPenaltyReason ?? ''),], null, 'A'.$row);$row++;}foreach ($sheet->getColumnIterator() as $column) {$sheet->getColumnDimension($column->getColumnIndex())->setAutoSize(true);}$sheet->getStyle('A1:N1')->getFont()->setBold(true);$rawCode = (string) ($event->getExtCode() ?? 'evento');$safeExtCode = preg_replace('/[^A-Za-z0-9_\-]/', '_', $rawCode);$fileName = (new \DateTime())->format('Ymd').'-'.$safeExtCode.'-calificaciones-finales.xlsx';$tmpFile = tempnam(sys_get_temp_dir(), 'final_qual_');if ($tmpFile === false) {throw new \RuntimeException('No se pudo crear el fichero temporal para la exportación.');}$writer = new Xlsx($spreadsheet);$writer->save($tmpFile);$response = new BinaryFileResponse($tmpFile, 200, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',]);$response->setContentDisposition('attachment', $fileName);$response->deleteFileAfterSend(true);return $response;}#[Route(path: '/admin/events/edit/{id}', name: 'app_admin_edit_event')]public function editEvent(Events $event, Request $request){if (!$this->RolesController->canEditEvent($event)) {$this->addFlash('error', 'No tienes permisos para editar este evento');return $this->redirectToRoute('app_events');}$form = $this->createForm(EventsFormType::class, $event);$form->handleRequest($request);if ($form->isSubmitted() && $form->isValid()) {if($event->getNumberCheckings() == null){ //Si el número necesario de fichajes está vacío lo calculamosif($event->getShift() == "" || $event->getShift() == "1") {$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.}else{$numberCheckings = $event->getTotalDays() * 2;}$event->setNumberCheckings($numberCheckings);}// if($event->getLocation() == null ? $event->setLocation(""): $event->getLocation() );$imageFile = $form->get('imageFile')->getData();if ($imageFile) {$newFilename = uniqid().'.'.$imageFile->guessExtension();$imageFile->move($this->getParameter('kernel.project_dir').'/public/images', $newFilename);$event->setImage($newFilename);}if ($request->request->get('remove_image')) {$event->setImage(null);}$event->setUpdated((new \DateTime()));$this->applyAutomaticEventOwnership($event);if (!$this->ensureEventCenterIsResolved($event, $form)) {return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}if (!$this->ensureEventAssignmentsAreValid($event, $form)) {return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}$this->entityManager->persist($event);$this->entityManager->flush();$this->addFlash('success', 'Evento guardado');$this->addTeacherHourWarningFlashes($event);return $this->redirectToRoute('app_events');}return $this->render('admin/events/eventform.html.twig', ['eventsForm' => $form]);}#[Route(path: '/admin/events/delete/{id}', name: 'app_admin_delete_event')]public function deleteEvent($id){$event = $this->eventsRepository->findOneBy(['id' => $id]);if (!$event instanceof Events || !$this->RolesController->canDeleteEvent($event)) {$this->addFlash('error', 'No tienes permisos para eliminar este evento');return $this->redirectToRoute('app_events');}$eventRegistrations = $event->getRegistrations();$numRegistrations = 0;$numAttendances = 0;foreach ($eventRegistrations as $registration){$eventAttendances = $registration->getAttendances();foreach($eventAttendances as $attendance){$numAttendances++;$this->entityManager->remove($attendance);}$numRegistrations++;$this->entityManager->remove($registration);}$this->entityManager->remove($event);$this->entityManager->flush();$this->addFlash('success', 'Se ha eliminado el Evento junto con '.$numRegistrations.' inscripciones y '.$numAttendances.' asistencias');return $this->redirectToRoute('app_events');}#[Route(path: '/admin/events/close/{id}', name: 'app_admin_close_event')]public function closeEvent(Request $request, $id){$event = $this->eventsRepository->find($id);if (!$event instanceof Events || !$this->RolesController->canFinalizeEvent($event)) {$this->addFlash('error', 'No tienes permisos para cerrar este evento');return $this->redirectToRoute('app_events');}$this->NotificationsController->sendCloseEventNotifications($event);$event->setUpdated((new \DateTime()));$event->setStatus(EventStatus::FINALIZADO);$this->entityManager->persist($event);$this->entityManager->flush();$this->addFlash('success', 'Evento cerrado correctamente');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}#[Route(path: '/admin/events/open/{id}', name: 'app_admin_open_event')]public function openEvent(Request $request, $id){$event = $this->eventsRepository->find($id);if (!$event instanceof Events || !$this->RolesController->canReopenEvent($event)) {$this->addFlash('error', 'No tienes permisos para abrir este evento');return $this->redirectToRoute('app_events');}$event->setUpdated((new \DateTime()));$event->setStatus(EventStatus::ABIERTO);$this->entityManager->persist($event);$this->entityManager->flush();$this->addFlash('success', 'Evento abierto correctamente');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}#[Route(path: '/admin/events/{id}/force-status', name: 'app_admin_force_event_status', methods: ['POST'])]public function forceEventStatus(Request $request, int $id): Response{if (!$this->isCsrfTokenValid('force_event_status_'.$id, $request->request->get('_token'))) {$this->addFlash('error', 'Token CSRF inválido');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$event = $this->eventsRepository->find($id);if (!$event) {$this->addFlash('error', 'Evento no encontrado');return $this->redirectToRoute('app_events');}$allow = $this->RolesController->checkAccessToEvent($event);if ($allow == false) {$this->addFlash('error', 'No tienes permisos para acceder a este evento');return $this->redirectToRoute('app_events');}if (!$this->RolesController->canForceEventStatus($event)) {$this->addFlash('error', 'No tienes permisos para forzar el estado del evento');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$statusValue = (string) $request->request->get('status', '');$status = EventStatus::tryFrom($statusValue);if (!$status) {$this->addFlash('error', 'Estado no válido');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$adjustmentRequired = $this->eventInscriptionEndDateAdjustmentService->requiresAdjustment($event, $status);$confirmInscriptionEndAdjustment = $request->request->getBoolean('confirm_inscription_end_adjustment');if ($adjustmentRequired && !$confirmInscriptionEndAdjustment) {$adjustmentContext = $this->eventInscriptionEndDateAdjustmentService->buildAdjustmentContext($event, $status);$this->addFlash('warning',sprintf('Debes confirmar el cierre manual de inscripciones. La fecha fin de inscripciones pasará de %s a %s.',$adjustmentContext['currentLabel'] ?? 'sin fecha',$adjustmentContext['newLabel']));return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$inscriptionEndAdjusted = $this->eventInscriptionEndDateAdjustmentService->applyAdjustmentIfRequired($event, $status);$event->setStatus($status);$event->setUpdated(new \DateTime());$this->entityManager->persist($event);$this->entityManager->flush();if ($inscriptionEndAdjusted) {$this->addFlash('success',sprintf('Estado del evento actualizado. La fecha fin de inscripciones se ha fijado en %s.',$event->getInscripcionesFin()?->format('d/m/Y') ?? (new \DateTimeImmutable('today'))->format('d/m/Y')));} else {$this->addFlash('success', 'Estado del evento actualizado.');}return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}#[Route(path: '/admin/events/sendregistrations/{id}', name: 'app_admin_send_registrations_event')]public function sendRegistrations(Request $request, MessageBusInterface $bus, $id){if(in_array('ROLE_SUPERUSER', $this->getUser()->getRoles(), true)|| in_array('ROLE_CENTER', $this->getUser()->getRoles(), true)|| in_array('ROLE_MANAGER', $this->getUser()->getRoles(), true)){$event = $this->eventsRepository->find($id);if (!$event instanceof Events || !$this->RolesController->checkAccessToEvent($event)) {$this->addFlash('error', "El usuario logueado no tiene permisos para realizar esta acción en el evento");return $this->redirectToRoute('app_events');}if (!$this->RolesController->hasCapability('VIEW_REGISTRATIONS')) {$this->addFlash('error', 'No tienes permisos para enviar las inscripciones del evento');return $this->redirectToRoute('app_events');}$eventRegistrations = $event->getActiveRegistrations();$urlbase = $this->paramsRepository->findOneBy(criteria: ['param' => "URL_BASE"])->getValue();$successCount = 0;$errorCount = 0;foreach ($eventRegistrations as $registration){$emailTo = $registration->getUserId()->getEmail();$registration->urlQrCode = $urlbase.$registration->getQrCode();$emailTemplate = 'notifications/registration.html.twig';$emailSubject = 'Inscripción: ' . $event->getName();$nameTo = $registration->getUserId()->getNomComplet();$emailContent = ['registrationEventName' => $event->getName(),'registrationUserName' => $registration->getUserId()->getNomComplet() . ' ' . $registration->getUserId()->getSurname(),'registrationUserDni' => $registration->getDni(),'registrationEventStartDate' => $event->getStartDate(),'registrationEventEndDate' => $event->getEndDate(),'registrationUrlQrCode' => $registration->urlQrCode,// 'registrationDetailedSpecs' => $event->getDetailedSpecs()];try {$this->bus->dispatch(new SendEmailMessage($emailTo,$emailTemplate,$emailSubject,$nameTo,$emailContent));$successCount++;} catch (\Exception $e) {$errorCount++;}}if ($errorCount === 0) {$this->addFlash('success', "Se han enviado las inscripciones a {$successCount} usuarios correctamente.");} elseif ($successCount === 0) {$this->addFlash('error', "Se ha producido un error en el envío de las inscripciones a {$errorCount} usuarios.");} else {$this->addFlash('warning', "Se han enviado las inscripciones correctamente a {$successCount} usuarios, pero se han producido errores en {$errorCount} inscripciones.");}return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$this->addFlash('error', 'No tienes permisos para enviar las inscripciones del evento');return $this->redirectToRoute('app_events');}#[Route('/acciones-formativas', name: 'public_events')]public function publicEvents(Request $request, PaginatorInterface $paginator,TaxonomyValueRepository $taxonomyValueRepository, HomeSliderRepository $homeSliderRepository): Response{$view = $request->query->get('view', 'list');$modalidadId = $request->query->get('modalidad');$modalidad = $modalidadId? $taxonomyValueRepository->find($modalidadId): null;$areaTematicaId = $request->query->get('areaTematica');$areaTematica = $areaTematicaId? $taxonomyValueRepository->find($areaTematicaId): null;$openInscription = $request->query->getBoolean('open_inscription');// Parámetros de filtro (si vienen vacíos se mantienen en null)$start_from = $request->query->get('start_from') ?: null;$start_to = $request->query->get('start_to') ?: null;$search = $request->query->get('q') ?: null;$code = $request->query->get('code') ?: null;$localidad = $request->query->get('localidad') ?: null;$duration_from = $request->query->get('durationFrom') ?: null;$duration_to = $request->query->get('durationTo') ?: null;// Query filtrada (repositorio)$query = $this->eventsRepository->getPublicFilteredQuery($search,$code,$modalidad,$areaTematica,$localidad,$start_from,$start_to,$duration_from,$duration_to,$openInscription);$view = $request->query->get('view', 'list');// Paginación (KNP paginator) para listado$events = $paginator->paginate($query,$request->query->getInt('page', 1),6);// Sin paginación para calendario (consulta independiente)$eventsCalendar = [];if ($view === 'calendar') {$calendarQuery = $this->eventsRepository->getPublicFilteredQuery($search,$code,$modalidad,$areaTematica,$localidad,$start_from,$start_to,$duration_from,$duration_to,$openInscription);$eventsCalendar = $calendarQuery->getResult();}// Lista de localidades para el select (simple: array vacío si no existe)// Si quieres poblar esto desde otra entidad, reemplaza por la consulta concreta.$localidades = [];$modalidades = $taxonomyValueRepository->findByTaxonomyLabel('Modalidad');$areasTematicas = $taxonomyValueRepository->findByTaxonomyLabel('Area Tematica');$hoursRange = $this->eventsRepository->getHoursRange();return $this->render('public/events/home.html.twig', ['events' => $events,'eventsCalendar' => $eventsCalendar,'search' => $search,'view' => $view,'code' => $code,'modalidad' => $modalidad,'modalidades' => $modalidades,'areaTematica' => $areaTematica,'areasTematicas' => $areasTematicas,'localidad' => $localidad,'start_from' => $start_from,'start_to' => $start_to,'duration_from' => $duration_from,'duration_to' => $duration_to,'localidades' => $localidades,'hoursRange' => $hoursRange,'sliderItems' => $homeSliderRepository->findEnabledOrdered(),]);}#[Route('/acciones-formativas/{extCode}', name: 'events_public_ficha')]public function ficha(string $extCode,EventsRepository $eventsRepository,EventRequestRepository $eventRequestRepository): Response {$event = $eventsRepository->findPublicByExtCode($extCode);if (!$event) {throw $this->createNotFoundException();}$user = $this->getUser();$hasRequest = false;if ($user) {$hasRequest = (bool) $eventRequestRepository->findOneByEventAndUser($event, $user);}return $this->render('public/events/event_public_info.html.twig', ['event' => $event,'hasRequest' => $hasRequest]);}#[Route('/acciones-formativas/{id}/pdf', name: 'event_public_pdf')]public function publicPdf(Events $event): Response{if (!$event->isPublished()) {throw $this->createNotFoundException();}$logoPdf = null;$logoParam = $this->paramsRepository->findOneBy(['param' => 'LOGO_PDF']);if ($logoParam && $logoParam->getValue()) {$logoFile = ltrim($logoParam->getValue(), '/');$logoPath = $this->getParameter('kernel.project_dir').'/public/'.$logoFile;if (is_file($logoPath)) {$ext = strtolower(pathinfo($logoPath, PATHINFO_EXTENSION));$mime = match ($ext) {'jpg', 'jpeg' => 'image/jpeg','gif' => 'image/gif','svg' => 'image/svg+xml',default => 'image/png',};$logoPdf = 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($logoPath));}}$html = $this->renderView('public/events/event_public_info_pdf.html.twig', ['event' => $event,'logoPdf' => $logoPdf,]);$options = new Options();$options->set('isHtml5ParserEnabled', true);$options->set('isPhpEnabled', true);$dompdf = new Dompdf($options);$dompdf->loadHtml($html);$dompdf->setPaper('A4', 'portrait');$dompdf->render();$fileName = date('Ymd') . '-' . $event->getExtCode() . '.pdf';return new Response($dompdf->output(),200,['Content-Type' => 'application/pdf','Content-Disposition' => 'attachment; filename="'.$fileName.'"']);}/*** @return array<int>*/private function getVisibleEventIds(): array{$eventIds = [];foreach ($this->RolesController->getEventsByUserAndRol() as $event) {$eventId = $event->getId();if ($eventId === null) {continue;}$eventIds[$eventId] = $eventId;}return array_values($eventIds);}/*** @return array{0: string, 1: string}*/private function resolveEventDataTableOrdering(Request $request): array{$query = $request->query->all();$order = $query['order'][0] ?? [];$columns = $query['columns'] ?? [];$columnIndex = (int) ($order['column'] ?? 0);$column = $columns[$columnIndex] ?? [];$columnName = (string) ($column['name'] ?? $column['data'] ?? 'startDate');return [$this->sanitizeEventOrderColumn($columnName),$this->sanitizeEventOrderDirection((string) ($order['dir'] ?? 'desc')),];}/*** @return array{0: string, 1: string}*/private function resolveEventExportOrdering(Request $request): array{return [$this->sanitizeEventOrderColumn((string) $request->query->get('orderColumn', 'startDate')),$this->sanitizeEventOrderDirection((string) $request->query->get('orderDir', 'desc')),];}/*** @return array<string, bool>*/private function buildEventActionVisibility(): array{return ['canView' => $this->RolesController->hasCapability('VIEW_EVENTS'),'canEdit' => $this->RolesController->hasCapability('EDIT_EVENTS'),'canDelete' => $this->RolesController->hasCapability('DELETE_EVENTS'),];}/*** @param array<string, mixed> $row* @param array<string, bool> $actionVisibility* @return array<string, mixed>*/private function buildEventTableRow(array $row, array $actionVisibility): array{$eventId = (int) ($row['id'] ?? 0);$statusValue = $row['status'] ?? '';if ($statusValue instanceof EventStatus) {$statusValue = $statusValue->value;}$statusValue = (string) $statusValue;$registrationMetrics = $this->buildEventRegistrationMetrics($row);return ['id' => $eventId,'published' => (bool) ($row['published'] ?? false),'publishedLabel' => (bool) ($row['published'] ?? false) ? 'Publicado' : 'Oculto','publishedTone' => (bool) ($row['published'] ?? false) ? 'published' : 'hidden','statusLabel' => $this->formatEventTableStatusLabel($statusValue),'statusTone' => $this->resolveEventStatusTone($statusValue),'extCode' => trim((string) ($row['extCode'] ?? '')),'name' => trim((string) ($row['name'] ?? '')),'startDate' => $this->formatEventDate($row['startDate'] ?? null),'startDateSort' => $this->formatEventDateSortValue($row['startDate'] ?? null),'endDate' => $this->formatEventDate($row['endDate'] ?? null),'endDateSort' => $this->formatEventDateSortValue($row['endDate'] ?? null),'requestCount' => max(0, (int) ($row['requestCount'] ?? 0)),'registrationCount' => $registrationMetrics['registrationCount'],'participantLimit' => $registrationMetrics['registrationLimit'],'registrationSummary' => $registrationMetrics['registrationSummary'],'registrationPercentLabel' => $registrationMetrics['registrationPercentLabel'],'registrationTone' => $registrationMetrics['registrationTone'],'registrationExceeded' => $registrationMetrics['registrationExceeded'],'registrationDetail' => $registrationMetrics['registrationDetail'],'canView' => $actionVisibility['canView'],'canEdit' => $actionVisibility['canEdit'],'canDelete' => $actionVisibility['canDelete'],'viewUrl' => $eventId > 0 ? $this->generateUrl('app_admin_view_event', ['id' => $eventId]) : null,'editUrl' => $eventId > 0 ? $this->generateUrl('app_admin_edit_event', ['id' => $eventId]) : null,'deleteUrl' => $eventId > 0 ? $this->generateUrl('app_admin_delete_event', ['id' => $eventId]) : null,'deleteLabel' => trim(((string) ($row['extCode'] ?? '') !== '' ? (string) $row['extCode'].' · ' : '').((string) ($row['name'] ?? '') !== '' ? (string) $row['name'] : 'evento seleccionado')),];}/*** @param array<string, mixed> $row* @return array{* registrationCount:int,* registrationLimit:?int,* registrationSummary:string,* registrationPercentLabel:string,* registrationTone:string,* registrationExceeded:bool,* registrationDetail:string* }*/private function buildEventRegistrationMetrics(array $row): array{$registrationCount = max(0, (int) ($row['registrationCount'] ?? 0));$participants = max(0, (int) ($row['participants'] ?? 0));$maxRegistrations = max(0, (int) ($row['maxRegistrations'] ?? 0));$registrationLimit = $maxRegistrations > 0? $maxRegistrations: ($participants > 0 ? $participants : null);if ($registrationLimit !== null) {$registrationPercent = ($registrationCount / $registrationLimit) * 100;$registrationSummary = $registrationCount.'/'.$registrationLimit;$registrationPercentLabel = number_format($registrationPercent, 2, ',', '.').' %';$registrationTone = $this->resolveEventRegistrationTone($registrationPercent);$registrationExceeded = $registrationPercent > 100;$registrationDetail = $maxRegistrations > 0? sprintf('%d de %d plazas ocupadas', $registrationCount, $registrationLimit): sprintf('%d de %d participantes previstos', $registrationCount, $registrationLimit);return ['registrationCount' => $registrationCount,'registrationLimit' => $registrationLimit,'registrationSummary' => $registrationSummary,'registrationPercentLabel' => $registrationPercentLabel,'registrationTone' => $registrationTone,'registrationExceeded' => $registrationExceeded,'registrationDetail' => $registrationDetail,];}return ['registrationCount' => $registrationCount,'registrationLimit' => null,'registrationSummary' => (string) $registrationCount,'registrationPercentLabel' => 'No aplica','registrationTone' => 'normal','registrationExceeded' => false,'registrationDetail' => 'Sin cupo máximo configurado',];}private function sanitizeEventOrderColumn(string $column): string{return in_array($column, ['published', 'status', 'extCode', 'name', 'startDate', 'endDate', 'requests', 'registrations'], true)? $column: 'startDate';}private function sanitizeEventOrderDirection(string $direction): string{return strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';}private function formatEventDate(mixed $value): string{if ($value instanceof \DateTimeInterface) {return $value->format('d-m-Y');}if (is_string($value) && trim($value) !== '') {try {return (new \DateTimeImmutable($value))->format('d-m-Y');} catch (\Throwable) {return $value;}}return 'Sin fecha';}private function formatEventDateSortValue(mixed $value): int{if ($value instanceof \DateTimeInterface) {return (int) $value->format('U');}if (is_string($value) && trim($value) !== '') {try {return (new \DateTimeImmutable($value))->getTimestamp();} catch (\Throwable) {return 0;}}return 0;}private function formatEventStatusLabel(string $status): string{return match ($status) {EventStatus::ABIERTO->value => 'Abierto',EventStatus::INSCRIPCIONES_CERRADAS->value => 'Inscripciones cerradas',EventStatus::EN_CURSO->value => 'En curso',EventStatus::FINALIZADO->value => 'Finalizado',default => trim(ucwords(strtolower(str_replace('_', ' ', $status)))),};}private function formatEventTableStatusLabel(string $status): string{return match ($status) {EventStatus::INSCRIPCIONES_CERRADAS->value => 'INS. CERRADAS',default => $this->formatEventStatusLabel($status),};}private function resolveEventStatusTone(string $status): string{return match ($status) {EventStatus::ABIERTO->value => 'open',EventStatus::INSCRIPCIONES_CERRADAS->value => 'warning',EventStatus::EN_CURSO->value => 'progress',EventStatus::FINALIZADO->value => 'finished',default => 'closed',};}private function resolveEventRegistrationTone(float $registrationPercent): string{if ($registrationPercent > 100) {return 'overflow';}if (abs($registrationPercent - 100.0) < 0.0001) {return 'full';}return 'normal';}private function getQualificationTypesConfig(Events $event): array{return $this->eventQualificationService->getQualificationTypesConfig($event);}/*** @param string[] $warnings*/private function addQualificationWarnings(array $warnings): void{foreach (array_slice($warnings, 0, 5) as $warning) {$this->addFlash('warning', $warning);}if (count($warnings) > 5) {$this->addFlash('warning', sprintf('Hay %d advertencias adicionales relacionadas con penalizaciones activas.', count($warnings) - 5));}}/*** @return array<int, array{* teacherId:int,* teacherName:string,* isInternal:bool,* accumulatedHours:int,* projectedHours:int,* warning:bool,* message:string* }>*/private function buildTeacherHourWarningsForEvent(Events $event): array{$teacherIds = [];foreach ($event->getCourseUser() as $teacher) {$teacherId = $teacher->getId();if ($teacherId === null) {continue;}$teacherIds[] = $teacherId;}if ($teacherIds === []) {return [];}$startDate = $event->getStartDate();$year = $startDate instanceof \DateTimeInterface ? (int) $startDate->format('Y') : null;return $this->teacherHourWarningService->buildWarnings($teacherIds,$year,$event->getId(),$event->getHours());}private function addTeacherHourWarningFlashes(Events $event): void{foreach ($this->teacherHourWarningService->buildFlashMessages($this->buildTeacherHourWarningsForEvent($event)) as $message) {$this->addFlash('warning', $message);}}private function resolveTeacherWarningYear(mixed $value): ?int{if ($value instanceof \DateTimeInterface) {return (int) $value->format('Y');}$normalizedValue = trim((string) ($value ?? ''));if (!preg_match('/^\d{4}$/', $normalizedValue)) {return null;}return (int) $normalizedValue;}#[Route(path: '/admin/events/{id}/manual-attendance', name: 'app_admin_event_manual_attendance', methods: ['POST'])]public function manualAttendance(Request $request, int $id, RegistrationsRepository $registrationsRepository): Response{$event = $this->eventsRepository->find($id);if (!$event) {$this->addFlash('error', 'Evento no encontrado.');return $this->redirectToRoute('app_events');}if (!$this->RolesController->checkAccessToEvent($event) || !$this->RolesController->hasCapability('CREATE_ATTENDANCES')) {$this->addFlash('error', 'No tienes permisos para acceder a este evento');return $this->redirectToRoute('app_events');}if ($event->getStatus() !== EventStatus::EN_CURSO) {$this->addFlash('error', 'Solo se pueden registrar asistencias cuando el curso está en estado En curso.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$registrationId = $request->request->get('registration_id');$date = $request->request->get('attendance_date');$time = $request->request->get('attendance_time');if (!$registrationId || !$date || !$time) {$this->addFlash('error', 'Faltan datos obligatorios para registrar la asistencia.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$registration = $registrationsRepository->find($registrationId);if (!$registration || $registration->getEventId()->getId() !== $event->getId()) {$this->addFlash('error', 'Inscripción no válida o no pertenece a este evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}try {$checkinTime = new \DateTime($date . ' ' . $time);} catch (\Exception $e) {$this->addFlash('error', 'Formato de fecha u hora no válido.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}// Validate date is within event rangeif (!$this->utilsController->isDateBetween($checkinTime, $event->getStartDate(), $event->getEndDate())) {$this->addFlash('error', 'La fecha de fichaje no está comprendida entre las fechas del evento.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$attendance = new Attendance();$attendance->setEventId($event);$attendance->setRegistration($registration);$attendance->setCheckinTime($checkinTime);$attendance->setCreatorUserId($this->getUser());$attendance->setMethod("WEB_ADMIN");$attendance->setDni($registration->getUserId()->getDni());$attendance->setQrCode($registration->getManualCode());$attendance->setCreated(new \DateTime());$attendance->setUpdated(new \DateTime());$registration->setLastScanned(new \DateTime());$this->entityManager->persist($attendance);$this->entityManager->persist($registration);$this->entityManager->flush();$this->addFlash('success', 'Asistencia manual registrada correctamente.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}#[Route(path: '/admin/events/{id}/import-attendance-csv', name: 'app_admin_event_import_attendance_csv', methods: ['POST'])]public function importAttendanceCsv(Request $request, int $id, RegistrationsRepository $registrationsRepository): Response{$event = $this->eventsRepository->find($id);if (!$event) {$this->addFlash('error', 'Evento no encontrado.');return $this->redirectToRoute('app_events');}if (!$this->RolesController->checkAccessToEvent($event) || !$this->RolesController->hasCapability('CREATE_ATTENDANCES')) {$this->addFlash('error', 'No tienes permisos para acceder a este evento');return $this->redirectToRoute('app_events');}if ($event->getStatus() !== EventStatus::EN_CURSO) {$this->addFlash('error', 'Solo se pueden registrar asistencias cuando el curso está en estado En curso.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$file = $request->files->get('csv_file');if (!$file) {$this->addFlash('error', 'No se ha proporcionado ningún archivo CSV.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}if ($file->getClientOriginalExtension() !== 'csv') {$this->addFlash('error', 'El archivo debe tener formato CSV.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}$successCount = 0;$errorCount = 0;$errors = [];if (($handle = fopen($file->getPathname(), "r")) !== false) {$rowNum = 0;$isFirstRow = true;while (($data = fgetcsv($handle, 1000, ";", '"', '\\')) !== false) {$rowNum++;// Strip UTF-8 BOM from first field of first rowif ($isFirstRow && isset($data[0])) {$data[0] = preg_replace('/^\xEF\xBB\xBF/', '', $data[0]);$isFirstRow = false;}// Skip header or badly formatted linesif (count($data) < 4) {$errors[] = "Fila $rowNum: Formato incorrecto, se esperan 4 columnas.";$errorCount++;continue;}$csvEventExtCode = trim($data[0]);$csvDni = trim($data[1]);$csvDate = trim($data[2]); // dd-mm-yyyy$csvTime = trim($data[3]); // hh:mm// Normalize unicode dashes (en-dash –, em-dash —) to regular hyphen$csvEventExtCode = str_replace(["\u{2013}", "\u{2014}"], '-', $csvEventExtCode);$csvDate = str_replace(["\u{2013}", "\u{2014}"], '-', $csvDate);// Skip header stringif (strtolower($csvDni) === 'dni') {continue;}if ((string) $csvEventExtCode !== (string) $event->getExtCode()) {$errors[] = "Fila $rowNum: El código de evento ($csvEventExtCode) no coincide con este evento (" . $event->getExtCode() . ").";$errorCount++;continue;}$registration = $registrationsRepository->findOneBy(['eventId' => $event, 'dni' => $csvDni, 'active' => true]);if (!$registration) {$errors[] = "Fila $rowNum: No se encontró la inscripción para el DNI ($csvDni).";$errorCount++;continue;}try {$checkinTime = \DateTime::createFromFormat('d-m-Y H:i', $csvDate . ' ' . $csvTime);if (!$checkinTime) {throw new \Exception("Invalid date format");}} catch (\Exception $e) {$errors[] = "Fila $rowNum: Formato de fecha ($csvDate $csvTime) no válido (esperado dd-mm-yyyy hh:mm).";$errorCount++;continue;}if (!$this->utilsController->isDateBetween($checkinTime, $event->getStartDate(), $event->getEndDate())) {$errors[] = "Fila $rowNum: La fecha de fichaje no está comprendida entre las fechas del evento.";$errorCount++;continue;}$attendance = new Attendance();$attendance->setEventId($event);$attendance->setRegistration($registration);$attendance->setCheckinTime($checkinTime);$attendance->setCreatorUserId($this->getUser());$attendance->setMethod("IMPORT_CSV");$attendance->setDni($csvDni);$attendance->setQrCode($registration->getManualCode());$attendance->setCreated(new \DateTime());$attendance->setUpdated(new \DateTime());$registration->setLastScanned(new \DateTime());$this->entityManager->persist($attendance);$this->entityManager->persist($registration);$successCount++;}fclose($handle);if ($successCount > 0) {$this->entityManager->flush();}} else {$this->addFlash('error', 'Error al leer el archivo CSV.');return $this->redirectToRoute('app_admin_view_event', ['id' => $id]);}if ($successCount > 0) {$this->addFlash('success', "Se han importado $successCount asistencias correctamente.");}if ($errorCount > 0) {$this->addFlash('error', "Se encontraron $errorCount errores en el archivo. Verifica el formato.");}return $this->redirectToRoute('app_admin_view_event', ['id' => $id, 'main_tab' => 'asistencias']);}private function applyAutomaticEventOwnership(Events $event): void{$user = $this->getUser();if (!$user instanceof User) {return;}if (in_array('ROLE_MANAGER', $user->getRoles(), true)) {$event->addManagerUser($user);}if ($event->getCenter() !== null) {return;}$defaultCenter = $this->roleAccessService->getDefaultEventCenter($user);if ($defaultCenter !== null) {$event->setCenter($defaultCenter);}}private function ensureEventCenterIsResolved(Events $event, FormInterface $form): bool{if ($event->getCenter() !== null) {return true;}$user = $this->getUser();if (!$user instanceof User) {return true;}$role = $this->roleAccessService->getPrimaryRole($user);if (!in_array($role, ['ROLE_CENTER', 'ROLE_MANAGER'], true)) {return true;}$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.'));return false;}private function ensureEventAssignmentsAreValid(Events $event, FormInterface $form): bool{$isValid = true;$center = $event->getCenter();if ($center === null) {if (!$event->getManagerUsers()->isEmpty()) {$form->get('managerUsers')->addError(new FormError('Selecciona un centro para poder asignar responsables de gestión.'));$isValid = false;}if (!$event->getCourseUser()->isEmpty()) {$form->get('courseUser')->addError(new FormError('Selecciona un centro para poder asignar docentes.'));$isValid = false;}return $isValid;}$centerId = $center->getId();foreach ($event->getManagerUsers() as $managerUser) {if (!in_array('ROLE_MANAGER', $managerUser->getRoles(), true)) {$form->get('managerUsers')->addError(new FormError('Todos los responsables de gestión deben tener el rol de gestor.'));$isValid = false;break;}if ($managerUser->getManagementCenter()?->getId() !== $centerId) {$form->get('managerUsers')->addError(new FormError('Todos los responsables de gestión deben pertenecer al centro seleccionado.'));$isValid = false;break;}}foreach ($event->getCourseUser() as $courseUser) {if (!in_array('ROLE_COURSE', $courseUser->getRoles(), true)) {$form->get('courseUser')->addError(new FormError('Todos los docentes seleccionados deben tener el rol docente.'));$isValid = false;break;}if ($courseUser->getManagementCenter()?->getId() !== $centerId) {$form->get('courseUser')->addError(new FormError('Todos los docentes deben pertenecer al centro seleccionado.'));$isValid = false;break;}}return $isValid;}}