find($personaResponsableId); if (!$persona) { return null; } $nombreCompleto = trim((string) (($persona->nombre ?? '') . ' ' . ($persona->apellido ?? ''))); return $nombreCompleto !== '' ? $nombreCompleto : null; }; $registrarLogSeguridad = function (Request $request, string $accionDescripcion, string $descripcion) use ($obtenerNombrePersonaLog): void { $accion = AccionLog::firstWhere('descripcion', $accionDescripcion); if (!$accion) { return; } $credencialIdSesion = (int) $request->session()->get('personal_credencial_id', 0); $personaResponsableId = Administrador::query() ->where('credencialprofesional_id', $credencialIdSesion) ->value('persona_id'); if (!$personaResponsableId) { return; } LogSeguridad::create([ 'descripcion' => $descripcion, 'fechahora' => now(), 'IPorigen' => (string) $request->ip(), 'rol' => 'ADMIN', 'persona_id' => (int) $personaResponsableId, 'accion_id' => $accion->id, 'accion_descripcion' => (string) ($accion->descripcion ?? $accionDescripcion), 'responsable_nombre' => $obtenerNombrePersonaLog((int) $personaResponsableId), ]); }; $registrarLogSeguridadProfesional = function (Request $request, string $accionDescripcion, string $descripcion) use ($obtenerNombrePersonaLog): void { $accion = AccionLog::firstWhere('descripcion', $accionDescripcion); if (!$accion) { return; } $credencialIdSesion = (int) $request->session()->get('personal_credencial_id', 0); $personaResponsableId = Profesional::query() ->where('credencialprofesional_id', $credencialIdSesion) ->value('persona_id'); if (!$personaResponsableId) { return; } LogSeguridad::create([ 'descripcion' => $descripcion, 'fechahora' => now(), 'IPorigen' => (string) $request->ip(), 'rol' => 'PROFESIONAL', 'persona_id' => (int) $personaResponsableId, 'accion_id' => $accion->id, 'accion_descripcion' => (string) ($accion->descripcion ?? $accionDescripcion), 'responsable_nombre' => $obtenerNombrePersonaLog((int) $personaResponsableId), ]); }; $registrarLogSeguridadDirecto = function (?int $personaResponsableId, string $rol, string $ipOrigen, string $accionDescripcion, string $descripcion) use ($obtenerNombrePersonaLog): void { if (!$personaResponsableId) { return; } $accion = AccionLog::firstWhere('descripcion', $accionDescripcion); if (!$accion) { return; } LogSeguridad::create([ 'descripcion' => $descripcion, 'fechahora' => now(), 'IPorigen' => $ipOrigen, 'rol' => $rol, 'persona_id' => (int) $personaResponsableId, 'accion_id' => $accion->id, 'accion_descripcion' => (string) ($accion->descripcion ?? $accionDescripcion), 'responsable_nombre' => $obtenerNombrePersonaLog((int) $personaResponsableId), ]); }; $validarSesionAdmin = function (Request $request) { if (!$request->session()->get('personal_auth') || $request->session()->get('personal_rol') !== 'ADMIN') { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como administradora.'); } return null; }; $honeypotValido = function (Request $request): bool { return trim((string) $request->input('website', '')) === ''; }; $hashearTokenRecuperacion = function (?string $token): string { return hash('sha256', trim((string) ($token ?? ''))); }; $buscarCredencialClientePorResetToken = function (string $token) use ($hashearTokenRecuperacion): ?CredencialCliente { $tokenPlano = trim((string) $token); if ($tokenPlano === '') { return null; } $tokenHasheado = $hashearTokenRecuperacion($tokenPlano); return CredencialCliente::query() ->where(function ($query) use ($tokenPlano, $tokenHasheado): void { $query->where('reset_token', $tokenHasheado) ->orWhere('reset_token', $tokenPlano); }) ->where('reset_expira_en', '>=', now()) ->first(); }; $buscarCredencialProfesionalPorResetToken = function (string $token, ?string $rol = null) use ($hashearTokenRecuperacion): ?CredencialProfesional { $tokenPlano = trim((string) $token); if ($tokenPlano === '') { return null; } $tokenHasheado = $hashearTokenRecuperacion($tokenPlano); $query = CredencialProfesional::query() ->where(function ($subQuery) use ($tokenPlano, $tokenHasheado): void { $subQuery->where('reset_token', $tokenHasheado) ->orWhere('reset_token', $tokenPlano); }) ->where('reset_expira_en', '>=', now()); if ($rol !== null && trim($rol) !== '') { $query->where('rol', strtoupper(trim($rol))); } return $query->first(); }; $normalizarCelular = function (?string $celular): string { return preg_replace('/\D+/', '', (string) ($celular ?? '')) ?? ''; }; $normalizarNombreArchivoPrivado = function (string $nombreArchivo): string { return basename(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, trim($nombreArchivo))); }; $directorioDocumentacionPrivada = function (int $clienteId): string { return storage_path('app/private/documentacion-clientes/cliente_' . $clienteId); }; $directorioDocumentacionPublicaLegacy = function (int $clienteId): string { return public_path('uploads/documentacion-clientes/cliente_' . $clienteId); }; $resolverRutaDocumentacionCliente = function (int $clienteId, string $nombreArchivo, bool $migrarLegacy = true) use ($normalizarNombreArchivoPrivado, $directorioDocumentacionPrivada, $directorioDocumentacionPublicaLegacy): ?string { $nombreSeguro = $normalizarNombreArchivoPrivado($nombreArchivo); if ($nombreSeguro === '') { return null; } $rutaPrivada = $directorioDocumentacionPrivada($clienteId) . DIRECTORY_SEPARATOR . $nombreSeguro; if (File::exists($rutaPrivada)) { return $rutaPrivada; } $rutaPublicaLegacy = $directorioDocumentacionPublicaLegacy($clienteId) . DIRECTORY_SEPARATOR . $nombreSeguro; if (!File::exists($rutaPublicaLegacy)) { return null; } if (!$migrarLegacy) { return $rutaPublicaLegacy; } if (!File::isDirectory($directorioDocumentacionPrivada($clienteId))) { File::makeDirectory($directorioDocumentacionPrivada($clienteId), 0755, true); } try { File::move($rutaPublicaLegacy, $rutaPrivada); return $rutaPrivada; } catch (\Throwable $e) { logger()->warning('No se pudo mover documentación sensible a almacenamiento privado.', [ 'cliente_id' => $clienteId, 'archivo' => $nombreSeguro, 'error' => $e->getMessage(), ]); return $rutaPublicaLegacy; } }; $eliminarDocumentacionCliente = function (int $clienteId, string $nombreArchivo) use ($normalizarNombreArchivoPrivado, $directorioDocumentacionPrivada, $directorioDocumentacionPublicaLegacy): void { $nombreSeguro = $normalizarNombreArchivoPrivado($nombreArchivo); if ($nombreSeguro === '') { return; } foreach ([ $directorioDocumentacionPrivada($clienteId) . DIRECTORY_SEPARATOR . $nombreSeguro, $directorioDocumentacionPublicaLegacy($clienteId) . DIRECTORY_SEPARATOR . $nombreSeguro, ] as $rutaArchivo) { if (File::exists($rutaArchivo)) { File::delete($rutaArchivo); } } }; $obtenerProfesionalesActivosCompatibles = function (int $profesionId) { return Profesional::query() ->where('baja_id', 1) ->where(function ($query) use ($profesionId): void { $query->where('profesion_id', $profesionId) ->orWhereHas('profesiones', fn ($q) => $q->where('profesiones.id', $profesionId)); }) ->pluck('id'); }; $resolverProfesionalCompatible = function (int $profesionalId, int $profesionId): ?Profesional { $profesionalBase = Profesional::query()->find($profesionalId); if (!$profesionalBase) { return null; } return Profesional::query() ->where('baja_id', 1) ->where(function ($query) use ($profesionalBase, $profesionalId): void { $query->where('id', $profesionalId); if ((int) ($profesionalBase->persona_id ?? 0) > 0) { $query->orWhere('persona_id', (int) $profesionalBase->persona_id); } if ((int) ($profesionalBase->credencialprofesional_id ?? 0) > 0) { $query->orWhere('credencialprofesional_id', (int) $profesionalBase->credencialprofesional_id); } }) ->where(function ($query) use ($profesionId): void { $query->where('profesion_id', $profesionId) ->orWhereHas('profesiones', fn ($q) => $q->where('profesiones.id', $profesionId)); }) ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$profesionalId]) ->first(); }; $formularioFueAceptadoPorOtroProfesional = function (int $formularioId, int $profesionalId): bool { $estadoGeneralFormulario = mb_strtolower(trim((string) Formulario::query() ->where('id', $formularioId) ->value('estado'))); if ($estadoGeneralFormulario !== 'aceptado') { return false; } return DB::table('profesionales_formularios') ->where('formulario_id', $formularioId) ->where('profesional_id', '!=', $profesionalId) ->whereRaw("LOWER(TRIM(estado)) = 'aceptado'") ->exists(); }; $recalcularEstadoGeneralFormulario = function (Formulario $formulario): string { $profesionFormularioId = (int) $formulario->profesion_id; $queryBase = DB::table('profesionales_formularios as pf') ->join('profesionales as p', 'p.id', '=', 'pf.profesional_id') ->where('pf.formulario_id', (int) $formulario->id) ->where('p.baja_id', 1) ->where(function ($query) use ($profesionFormularioId): void { $query->where('p.profesion_id', $profesionFormularioId) ->orWhereExists(function ($subquery) use ($profesionFormularioId): void { $subquery->select(DB::raw(1)) ->from('profesionales_profesiones as pp') ->whereColumn('pp.profesional_id', 'p.id') ->where('pp.profesion_id', $profesionFormularioId); }); }); $hayAceptado = (clone $queryBase) ->whereRaw("LOWER(TRIM(pf.estado)) = 'aceptado'") ->exists(); if ($hayAceptado) { $nuevoEstado = 'Aceptado'; } else { $asignadosActivos = (clone $queryBase)->count(); $hayActivosSinRechazar = (clone $queryBase) ->where(function ($query): void { $query->whereNull('pf.estado') ->orWhereRaw("LOWER(TRIM(pf.estado)) <> 'rechazado'"); }) ->exists(); $nuevoEstado = ($asignadosActivos > 0 && !$hayActivosSinRechazar) ? 'Rechazado por todos' : 'Pendiente'; } if (trim((string) $formulario->estado) !== $nuevoEstado) { $formulario->update([ 'estado' => $nuevoEstado, ]); } return $nuevoEstado; }; $enviarCorreoTurno = function ( string $tipo, string $correoDestino, string $nombreDestino, \Illuminate\Support\Carbon $inicio, string $servicio, string $modalidad, string $nombreProfesional ): void { $correo = trim($correoDestino); if ($correo === '') { return; } $nombreCliente = trim($nombreDestino) !== '' ? trim($nombreDestino) : 'paciente'; $fecha = $inicio->format('d/m/Y'); $hora = $inicio->format('H:i'); $servicioTxt = trim($servicio) !== '' ? trim($servicio) : 'Sin servicio'; $modalidadTxt = trim($modalidad) !== '' ? trim($modalidad) : 'Sin modalidad'; $profesionalTxt = trim($nombreProfesional) !== '' ? trim($nombreProfesional) : 'profesional asignado'; $esCancelacion = $tipo === 'cancelacion'; $esReprogramacion = $tipo === 'reprogramacion'; $tipoNotificacion = $esCancelacion ? 'turno_cancelado' : ($esReprogramacion ? 'turno_reprogramado' : 'turno_otorgado'); $plantillasPorTipo = [ 'turno_otorgado' => [ 'mensaje_inicio' => 'Se asigno tu turno con los siguientes datos:', 'mensaje_final' => 'Si necesitas reprogramar o cancelar, por favor comunicate por WhatsApp al 343-4632444', ], 'turno_cancelado' => [ 'mensaje_inicio' => 'Te informamos que tu turno fue cancelado con los siguientes datos:', 'mensaje_final' => 'Si necesitas coordinar un nuevo turno, por favor comunicate por WhatsApp al 343-4632444', ], 'turno_reprogramado' => [ 'mensaje_inicio' => 'Te informamos que tu turno fue reprogramado con los siguientes datos:', 'mensaje_final' => 'Si tenes dudas sobre este cambio, por favor comunicate por WhatsApp al 343-4632444', ], ]; $plantilla = Notificacion::query()->firstOrCreate( ['tipo' => $tipoNotificacion], $plantillasPorTipo[$tipoNotificacion] ); $mensajeInicio = trim((string) ($plantilla->mensaje_inicio ?? '')); $mensajeFinal = trim((string) ($plantilla->mensaje_final ?? '')); $asunto = $esCancelacion ? 'Cancelacion de turno' : ($esReprogramacion ? 'Reprogramacion de turno' : 'Confirmacion de turno asignado'); $intro = $mensajeInicio !== '' ? $mensajeInicio : ($plantillasPorTipo[$tipoNotificacion]['mensaje_inicio'] ?? ''); $ubicacion = "Dr. Luis Pasteur 141, Paraná, Entre Ríos, Argentina"; $cuerpo = "Hola {$nombreCliente},\n\n" . $intro . "\n" . "- Fecha: {$fecha}\n" . "- Hora: {$hora}\n" . "- Servicio: {$servicioTxt}\n" . "- Modalidad: {$modalidadTxt}\n" . "- Profesional: {$profesionalTxt}\n" . "- Ubicación: {$ubicacion}\n\n" . ($mensajeFinal !== '' ? $mensajeFinal : ($plantillasPorTipo[$tipoNotificacion]['mensaje_final'] ?? '')) . "\n"; try { Mail::raw($cuerpo, function ($message) use ($correo, $nombreCliente, $asunto): void { $message->to($correo, $nombreCliente)->subject($asunto); }); } catch (\Throwable $e) { logger()->warning('No se pudo enviar correo de turno.', [ 'tipo' => $tipo, 'correo' => $correo, 'error' => $e->getMessage(), ]); } }; // Devuelve advertencias de agenda/configuración. Si la agenda aún no tiene días de atención cargados, // permite la asignación manual sin advertir por falta de configuración horaria. $obtenerAdvertenciasTurno = function (int $agendaId, int $duracionMinutos, \Illuminate\Support\Carbon $inicio): array { $fechaStr = $inicio->format('Y-m-d'); $finTurno = $inicio->copy()->addMinutes($duracionMinutos); $advertencias = []; $esFeriado = Feriado::query() ->where('agenda_id', $agendaId) ->where('fecha', $fechaStr) ->exists(); if ($esFeriado) { $advertencias[] = 'El día elegido es feriado.'; } $enVacaciones = ModoVacaciones::query() ->where('agenda_id', $agendaId) ->where('inicio', '<=', $fechaStr) ->where('fin', '>=', $fechaStr) ->exists(); if ($enVacaciones) { $advertencias[] = 'El profesional se encuentra en período de vacaciones en esa fecha.'; } $diaDescPorNumero = [ 0 => ['domingo'], 1 => ['lunes'], 2 => ['martes'], 3 => ['miercoles', 'miércoles'], 4 => ['jueves'], 5 => ['viernes'], 6 => ['sabado', 'sábado'], ]; $diasPos = $diaDescPorNumero[(int) $inicio->dayOfWeek] ?? []; $tieneDiasConfigurados = DiaDeAtencion::query() ->where('agenda_id', $agendaId) ->exists(); $diaAtencion = DiaDeAtencion::query() ->with(['horariosAtenciones', 'horariosRecesos']) ->where('agenda_id', $agendaId) ->whereHas('dias', function ($q) use ($diasPos) { $q->whereRaw( 'LOWER(TRIM(descripcion)) IN (' . implode(',', array_fill(0, count($diasPos), '?')) . ')', $diasPos ); }) ->first(); if (!$diaAtencion) { if ($tieneDiasConfigurados) { $advertencias[] = 'El día elegido no forma parte de los días laborales configurados.'; } return $advertencias; } $inicioStr = $inicio->format('H:i:s'); $finStr = $finTurno->format('H:i:s'); if ($diaAtencion->horariosAtenciones->isNotEmpty()) { $dentroDeHorario = $diaAtencion->horariosAtenciones->contains(function ($horario) use ($inicioStr, $finStr) { $hi = substr((string) $horario->horariocomienzo, 0, 8); $hf = substr((string) $horario->horariofin, 0, 8); return $inicioStr >= $hi && $finStr <= $hf; }); if (!$dentroDeHorario) { $advertencias[] = 'El horario elegido está fuera del horario de atención configurado.'; } } $enReceso = $diaAtencion->horariosRecesos->contains(function ($receso) use ($inicioStr, $finStr) { $ri = substr((string) $receso->comienzo, 0, 8); $rf = substr((string) $receso->fin, 0, 8); return $inicioStr < $rf && $finStr > $ri; }); if ($enReceso) { $advertencias[] = 'El horario elegido cae dentro de un receso.'; } return $advertencias; }; $verificarConflictoTurno = function (int $agendaId, int $duracionMinutos, \Illuminate\Support\Carbon $inicio, ?int $turnoIgnoradoId = null): ?string { $finTurno = $inicio->copy()->addMinutes($duracionMinutos); $estadoCanceladoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['cancelado']) ->value('id') ?? 2); $hayConflicto = Turno::query() ->where('agenda_id', $agendaId) ->where('estadoturno_id', '!=', $estadoCanceladoId) ->when($turnoIgnoradoId !== null, function ($query) use ($turnoIgnoradoId) { $query->where('id', '!=', $turnoIgnoradoId); }) ->where('inicio', '<', $finTurno) ->whereRaw('DATE_ADD(inicio, INTERVAL ? MINUTE) > ?', [$duracionMinutos, $inicio]) ->exists(); if ($hayConflicto) { return 'Ya existe un turno en ese horario.'; } return null; }; // Retorna null si el horario está disponible, o un string con el motivo si no lo está. $verificarDisponibilidadTurno = function (int $agendaId, int $duracionMinutos, \Illuminate\Support\Carbon $inicio, ?int $turnoIgnoradoId = null) use ($obtenerAdvertenciasTurno, $verificarConflictoTurno): ?string { $advertencias = $obtenerAdvertenciasTurno($agendaId, $duracionMinutos, $inicio); if (!empty($advertencias)) { return implode(' ', $advertencias); } return $verificarConflictoTurno($agendaId, $duracionMinutos, $inicio, $turnoIgnoradoId); }; // Busca el primer turno disponible desde el dia siguiente, respetando agenda y preferencias del formulario. $buscarTurnoAutomatico = function (Formulario $formulario, int $profesionalId) use ($verificarDisponibilidadTurno): ?array { $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => $profesionalId], ['estado' => 'Activa', 'duracionturno' => 30] ); $agenda->load(['diaDeAtencion.dias', 'diaDeAtencion.horariosAtenciones']); $duracion = (int) ($agenda->duracionturno ?? 30); $numeroDiaSemana = [ 'domingo' => 0, 'lunes' => 1, 'martes' => 2, 'miercoles' => 3, 'miércoles' => 3, 'jueves' => 4, 'viernes' => 5, 'sabado' => 6, 'sábado' => 6, ]; $horariosPorDia = []; foreach ($agenda->diaDeAtencion ?? [] as $diaAtencion) { $diaDesc = mb_strtolower(trim((string) ($diaAtencion->dias?->descripcion ?? ''))); $diaNumero = $numeroDiaSemana[$diaDesc] ?? null; if ($diaNumero === null) { continue; } foreach ($diaAtencion->horariosAtenciones ?? [] as $horario) { if (!$horario->horariocomienzo || !$horario->horariofin) { continue; } $horariosPorDia[$diaNumero][] = [ 'inicio' => substr((string) $horario->horariocomienzo, 0, 8), 'fin' => substr((string) $horario->horariofin, 0, 8), ]; } } $diasPreferidos = collect($formulario->diasPreferidos ?? []) ->map(fn ($dia) => mb_strtolower(trim((string) ($dia->descripcion ?? '')))) ->filter() ->values(); $diasPermitidos = null; if ($diasPreferidos->isNotEmpty() && !$diasPreferidos->contains('indistinto')) { $diasPermitidos = $diasPreferidos ->map(fn ($dia) => $numeroDiaSemana[$dia] ?? null) ->filter(fn ($diaNumero) => $diaNumero !== null) ->unique() ->values() ->all(); } $horariosPreferidos = collect($formulario->horariosPreferidos ?? []) ->map(fn ($horario) => mb_strtolower(trim((string) ($horario->descripcion ?? '')))) ->filter() ->values(); $preferenciaAM = $horariosPreferidos->contains('am'); $preferenciaPM = $horariosPreferidos->contains('pm'); $preferenciaIndistinto = $horariosPreferidos->contains('indistinto'); $cumpleHorarioPreferido = function (\Illuminate\Support\Carbon $inicioTurno) use ($preferenciaAM, $preferenciaPM, $preferenciaIndistinto): bool { if ($preferenciaIndistinto || (!$preferenciaAM && !$preferenciaPM) || ($preferenciaAM && $preferenciaPM)) { return true; } $hora = (int) $inicioTurno->format('H'); if ($preferenciaAM) { return $hora < 12; } if ($preferenciaPM) { return $hora >= 12; } return true; }; $inicioBusqueda = now()->addDay()->startOfDay(); $finBusqueda = now()->addDays(90)->endOfDay(); $cursor = $inicioBusqueda->copy(); while ($cursor->lte($finBusqueda)) { $diaSemana = (int) $cursor->dayOfWeek; if (is_array($diasPermitidos) && !in_array($diaSemana, $diasPermitidos, true)) { $cursor->addDay(); continue; } $horariosDelDia = $horariosPorDia[$diaSemana] ?? []; usort($horariosDelDia, fn ($a, $b) => strcmp((string) $a['inicio'], (string) $b['inicio'])); foreach ($horariosDelDia as $rango) { $inicioRango = \Illuminate\Support\Carbon::parse($cursor->format('Y-m-d') . ' ' . $rango['inicio']); $finRango = \Illuminate\Support\Carbon::parse($cursor->format('Y-m-d') . ' ' . $rango['fin']); $slot = $inicioRango->copy(); while ($slot->copy()->addMinutes($duracion)->lte($finRango)) { if ($slot->lt($inicioBusqueda)) { $slot->addMinutes($duracion); continue; } if (!$cumpleHorarioPreferido($slot)) { $slot->addMinutes($duracion); continue; } $errorDisponibilidad = $verificarDisponibilidadTurno((int) $agenda->id, $duracion, $slot->copy()); if ($errorDisponibilidad === null) { return [ 'agenda_id' => (int) $agenda->id, 'duracion' => $duracion, 'inicio' => $slot->copy(), 'fecha' => $slot->format('Y-m-d'), 'hora' => $slot->format('H:i'), ]; } $slot->addMinutes($duracion); } } $cursor->addDay(); } return null; }; Route::get('/', function () { $contenidoWeb = ContenidoWeb::latest('id')->first(); $quienesSomos = $contenidoWeb?->quienessomos; $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); $profesionales = Profesional::with(['persona.Foto', 'profesion', 'profesiones']) ->where('baja_id', 1) ->get() ->groupBy('persona_id') ->map(function ($profesionalesPorPersona) { $profesionalBase = $profesionalesPorPersona->first(); $profesionesCombinadas = $profesionalesPorPersona ->flatMap(function ($profesional) { return $profesional->profesiones ->push($profesional->profesion) ->filter(); }) ->unique('id') ->values(); $profesionalBase->setRelation('profesiones', $profesionesCombinadas); return $profesionalBase; }) ->values(); $servicios = Servicio::with('foto') ->where('estado', 'activo') ->where('visibleenweb', 'si') ->get(); $profesiones = Profesion::query() ->where('visible_en_formulario', true) ->orderBy('titulo') ->get(); $modalidades = Modalidad::query() ->orderBy('descripcion') ->get(); $mensajeBurbujaAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_BURBUJA) ->value('respuesta') ?? '¡Hola, soy Clara!, ¿te puedo ayudar en algo?'; $mensajeInicioPanelAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_PANEL_INICIO) ->value('respuesta') ?? 'Hola, soy Clara, la asistente virtual de Abogadas del Litoral. Escribí una palabra clave con el tema con el que necesites ayuda.'; $nombreAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_NOMBRE) ->value('respuesta') ?? 'Clara'; $chipsAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_CHIPS) ->orderBy('orden') ->orderBy('id') ->pluck('respuesta') ->flatMap(fn ($respuesta) => explode("\n", (string) $respuesta)) ->map(fn ($c) => trim($c)) ->filter(fn ($c) => $c !== '') ->unique() ->values() ->all(); return view('welcome', [ 'quienesSomos' => $quienesSomos, 'versionSitio' => $versionSitio, 'profesionales' => $profesionales, 'servicios' => $servicios, 'profesiones' => $profesiones, 'modalidades' => $modalidades, 'mensajeBurbujaAsistente' => $mensajeBurbujaAsistente, 'mensajeInicioPanelAsistente' => $mensajeInicioPanelAsistente, 'nombreAsistente' => $nombreAsistente, 'chipsAsistente' => $chipsAsistente, ]); }); Route::get('/instrucciones-uso', function () { return view('instrucciones-uso'); }); Route::post('/asistente/chat', function (Request $request) { $validated = $request->validate([ 'mensaje' => ['required', 'string', 'max:500'], ]); $mensaje = mb_strtolower(trim((string) $validated['mensaje'])); $mensajeError = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_ERROR) ->value('respuesta') ?? 'Lo siento, no tengo una respuesta exacta para eso.'; $respuesta = $mensajeError; $encontrado = false; try { $faqs = FaqAsistente::query() ->where('activo', true) ->whereNotIn('intencion', [ FAQ_INTENCION_BURBUJA, FAQ_INTENCION_PANEL_INICIO, FAQ_INTENCION_ERROR, FAQ_INTENCION_NOMBRE, FAQ_INTENCION_CHIPS, ]) ->orderBy('orden') ->orderBy('id') ->get(); foreach ($faqs as $faq) { $palabrasClave = collect($faq->palabras_clave) ->filter(fn ($item) => is_string($item) && trim($item) !== '') ->map(fn ($item) => mb_strtolower(trim($item))) ->values(); $coincide = $palabrasClave->contains(fn ($keyword) => str_contains($mensaje, $keyword)); if ($coincide) { $respuesta = (string) $faq->respuesta; $encontrado = true; break; } } } catch (QueryException $e) { // Si la tabla aún no existe, mantener fallback por defecto. } if (str_contains($respuesta, '{cantidad_servicios}')) { $cantidadServicios = Servicio::query() ->where('estado', 'activo') ->where('visibleenweb', 'si') ->count(); $respuesta = str_replace('{cantidad_servicios}', (string) $cantidadServicios, $respuesta); } if (str_contains($respuesta, '{cantidad_profesionales}')) { $cantidadProfesionales = Profesional::query() ->where('baja_id', 1) ->count(); $respuesta = str_replace('{cantidad_profesionales}', (string) $cantidadProfesionales, $respuesta); } if (!$encontrado) { try { AsistenteSinRespuesta::create(['consulta' => trim((string) $validated['mensaje'])]); } catch (QueryException $e) { // Tabla aún no migrada. } } return response()->json([ 'respuesta' => $respuesta, ]); })->middleware('throttle:asistente-chat'); Route::get('/reportar-falla', function (Request $request) { $origen = trim((string) $request->query('origen', '')); return view('reportar-falla', [ 'origen' => $origen, ]); }); Route::post('/reportar-falla', function (Request $request) { $validated = $request->validate([ 'titulo' => ['required', 'string', 'max:255'], 'descripcion' => ['required', 'string', 'max:4500'], 'origen' => ['nullable', 'string', 'max:1000'], 'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], 'captura_pantalla' => ['nullable', 'string', 'max:4000000'], ]); $descripcion = trim($validated['descripcion']); $origen = trim((string) ($validated['origen'] ?? '')); $fotoBugId = null; if ($origen !== '') { $descripcion .= "\n\nOrigen: " . $origen; } $fotoFile = $request->file('foto'); if ($fotoFile) { $directorio = public_path('images/bugs'); File::ensureDirectoryExists($directorio); $extension = strtolower((string) $fotoFile->getClientOriginalExtension()); $mimeType = (string) $fotoFile->getClientMimeType(); $tamanioBytes = (int) $fotoFile->getSize(); $nombreArchivo = now()->format('YmdHis') . '_' . uniqid('bug_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $fotoBug = FotoBug::create([ 'extension' => $extension, 'tamanio_bytes' => $tamanioBytes, 'nombre' => $nombreArchivo, 'mime_type' => $mimeType, ]); $fotoBugId = $fotoBug->id; } // Si no se adjuntó foto manual pero hay captura automática de pantalla, usarla. if ($fotoBugId === null) { $capturaBase64 = trim((string) ($validated['captura_pantalla'] ?? '')); if ($capturaBase64 !== '' && str_starts_with($capturaBase64, 'data:image/')) { try { $partes = explode(',', $capturaBase64, 2); $datosImagen = base64_decode($partes[1] ?? ''); if ($datosImagen !== false && strlen($datosImagen) > 0) { $directorio = public_path('images/bugs'); File::ensureDirectoryExists($directorio); $nombreArchivo = now()->format('YmdHis') . '_' . uniqid('cap_', true) . '.jpg'; file_put_contents($directorio . '/' . $nombreArchivo, $datosImagen); $fotoBug = FotoBug::create([ 'extension' => 'jpg', 'tamanio_bytes' => strlen($datosImagen), 'nombre' => $nombreArchivo, 'mime_type' => 'image/jpeg', ]); $fotoBugId = $fotoBug->id; } } catch (\Throwable) { // Si falla, continuar sin captura. } } } Bug::create([ 'titulo' => $validated['titulo'], 'descripcion' => $descripcion, 'prioridad' => 'Media', 'estado' => 'Pendiente', 'version' => 'Web', 'fotobug_id' => $fotoBugId, ]); return redirect('/reportar-falla' . ($origen !== '' ? '?origen=' . urlencode($origen) : '')) ->with('bug_success', 'La falla fue reportada correctamente.'); })->middleware('throttle:reportar-bugs'); Route::post('/formulario', function (Request $request) use ($obtenerProfesionalesActivosCompatibles, $honeypotValido, $resolverProfesionalCompatible) { $validated = $request->validate([ 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'correo' => ['required', 'email', 'max:255'], 'celular' => ['required', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'profesion_id' => ['required', 'exists:profesiones,id'], 'servicio_id' => [ 'required', Rule::exists('servicios', 'id')->where(fn ($query) => $query ->where('profesion_id', $request->input('profesion_id')) ->where('visibleenweb', 'si') ->where('estado', 'activo')), ], 'profesional_id' => [ 'required', function (string $attribute, mixed $value, \Closure $fail) use ($request, $resolverProfesionalCompatible): void { if ($value === 'INDISTINTO') { return; } $profesionalCompatible = $resolverProfesionalCompatible((int) $value, (int) $request->input('profesion_id')); if (!$profesionalCompatible) { $fail('El profesional seleccionado no es valido para la profesion elegida.'); } }, ], 'modalidad_id' => ['nullable'], 'dias_preferencia' => [ 'required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { if (!is_array($value)) { return; } if (in_array('Indistinto', $value, true) && count($value) > 1) { $fail('No podes elegir "Indistinto" junto con otros dias.'); } }, ], 'dias_preferencia.*' => ['required', 'string', 'in:Lunes,Martes,Miercoles,Jueves,Viernes,Indistinto'], 'horario_preferencia' => ['required', 'string', 'in:AM,PM,INDISTINTO'], 'descripcion' => ['required', 'string', 'max:3000'], 'website' => ['nullable', 'string', 'max:255'], ]); if (!$honeypotValido($request)) { return redirect('/#formulario') ->withErrors(['formulario' => 'No se pudo enviar el formulario.']) ->withInput(); } $profesionalSeleccionadoId = null; if ($validated['profesional_id'] !== 'INDISTINTO') { $profesionalCompatible = $resolverProfesionalCompatible((int) $validated['profesional_id'], (int) $validated['profesion_id']); $profesionalSeleccionadoId = $profesionalCompatible ? (int) $profesionalCompatible->id : null; } $formulario = Formulario::create([ 'nombrecompleto' => trim($validated['nombre'] . ' ' . $validated['apellido']), 'correo' => $validated['correo'], 'celular' => $validated['celular'], 'ip_origen' => (string) $request->ip(), 'profesion_id' => (int) $validated['profesion_id'], 'servicio_id' => (int) $validated['servicio_id'], 'profesional_id' => $profesionalSeleccionadoId, 'modalidad_id' => 1, 'descripcion' => $validated['descripcion'], 'fechaenvio' => now()->toDateString(), ]); $diasIds = collect($validated['dias_preferencia']) ->map(fn ($descripcion) => DiaPreferencia::firstOrCreate(['descripcion' => $descripcion])->id) ->values() ->all(); $horarioId = HorarioPreferencia::firstOrCreate([ 'descripcion' => $validated['horario_preferencia'], ])->id; $formulario->diasPreferidos()->sync($diasIds); $formulario->horariosPreferidos()->sync([$horarioId]); $profesionalesIds = $obtenerProfesionalesActivosCompatibles((int) $validated['profesion_id']); $asignaciones = $profesionalesIds ->map(fn ($profesionalId) => [ 'profesional_id' => (int) $profesionalId, 'formulario_id' => (int) $formulario->id, 'estado' => 'Pendiente', ]) ->values() ->all(); if (!empty($asignaciones)) { DB::table('profesionales_formularios')->insertOrIgnore($asignaciones); } return redirect('/#formulario')->with('form_success', 'Formulario enviado correctamente.'); }); Route::post('/cliente/formulario', function (Request $request) use ($obtenerProfesionalesActivosCompatibles, $honeypotValido, $resolverProfesionalCompatible) { if (!$request->session()->get('cliente_auth')) { return redirect('/login/cliente')->with('login_error', 'Debes iniciar sesión como cliente.'); } $validated = $request->validate([ 'profesion_id' => ['required', 'exists:profesiones,id'], 'servicio_id' => [ 'required', Rule::exists('servicios', 'id')->where(fn ($query) => $query ->where('profesion_id', $request->input('profesion_id')) ->where('visibleenweb', 'si') ->where('estado', 'activo')), ], 'profesional_id' => [ 'required', function (string $attribute, mixed $value, \Closure $fail) use ($request, $resolverProfesionalCompatible): void { if ($value === 'INDISTINTO') { return; } $profesionalCompatible = $resolverProfesionalCompatible((int) $value, (int) $request->input('profesion_id')); if (!$profesionalCompatible) { $fail('El profesional seleccionado no es valido para la profesion elegida.'); } }, ], 'modalidad_id' => ['nullable'], 'dias_preferencia' => [ 'required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { if (!is_array($value)) { return; } if (in_array('Indistinto', $value, true) && count($value) > 1) { $fail('No podes elegir "Indistinto" junto con otros dias.'); } }, ], 'dias_preferencia.*' => ['required', 'string', 'in:Lunes,Martes,Miercoles,Jueves,Viernes,Indistinto'], 'horario_preferencia' => ['required', 'string', 'in:AM,PM,INDISTINTO'], 'descripcion' => ['required', 'string', 'max:3000'], 'website' => ['nullable', 'string', 'max:255'], ]); if (!$honeypotValido($request)) { return redirect('/cliente/dashboard#formulario') ->withErrors(['formulario' => 'No se pudo enviar el formulario.']) ->withInput(); } $clienteCorreoSesion = trim((string) $request->session()->get('cliente_correo', '')); $cliente = Cliente::query() ->with(['persona.telefonos', 'credencialCliente']) ->whereHas('credencialCliente', fn ($query) => $query->where('correo', $clienteCorreoSesion)) ->first(); $nombre = trim((string) ($cliente?->persona?->nombre ?? $request->session()->get('cliente_nombre', ''))); $apellido = trim((string) ($cliente?->persona?->apellido ?? $request->session()->get('cliente_apellido', ''))); $correo = trim((string) ($cliente?->correo ?? $cliente?->credencialCliente?->correo ?? $clienteCorreoSesion)); $celular = trim((string) ($cliente?->persona?->telefonos?->first()?->telefono ?? $request->session()->get('cliente_celular', ''))); if (!$cliente || !$cliente->id) { return redirect('/cliente/dashboard#formulario') ->with('form_error', 'No se pudo identificar tu cuenta de cliente. Volvé a iniciar sesión.'); } if ($nombre === '' || $apellido === '' || $correo === '' || $celular === '') { return redirect('/cliente/dashboard#formulario') ->with('form_error', 'No se pudieron completar automaticamente tus datos personales. Verificá tus datos de cliente.'); } $profesionalSeleccionadoId = null; if ($validated['profesional_id'] !== 'INDISTINTO') { $profesionalCompatible = $resolverProfesionalCompatible((int) $validated['profesional_id'], (int) $validated['profesion_id']); $profesionalSeleccionadoId = $profesionalCompatible ? (int) $profesionalCompatible->id : null; } $formulario = Formulario::create([ 'nombrecompleto' => trim($nombre . ' ' . $apellido), 'correo' => $correo, 'celular' => $celular, 'ip_origen' => (string) $request->ip(), 'profesion_id' => (int) $validated['profesion_id'], 'servicio_id' => (int) $validated['servicio_id'], 'profesional_id' => $profesionalSeleccionadoId, 'modalidad_id' => 1, 'cliente_id' => (int) $cliente->id, 'descripcion' => $validated['descripcion'], 'fechaenvio' => now()->toDateString(), ]); $diasIds = collect($validated['dias_preferencia']) ->map(fn ($descripcion) => DiaPreferencia::firstOrCreate(['descripcion' => $descripcion])->id) ->values() ->all(); $horarioId = HorarioPreferencia::firstOrCreate([ 'descripcion' => $validated['horario_preferencia'], ])->id; $formulario->diasPreferidos()->sync($diasIds); $formulario->horariosPreferidos()->sync([$horarioId]); $profesionalesIds = $obtenerProfesionalesActivosCompatibles((int) $validated['profesion_id']); $asignaciones = $profesionalesIds ->map(fn ($profesionalId) => [ 'profesional_id' => (int) $profesionalId, 'formulario_id' => (int) $formulario->id, 'estado' => 'Pendiente', ]) ->values() ->all(); if (!empty($asignaciones)) { DB::table('profesionales_formularios')->insertOrIgnore($asignaciones); } return redirect('/cliente/dashboard#formulario')->with('form_success', 'Formulario enviado correctamente.'); }); Route::get('/login/cliente', function (Request $request) { $contenidoWeb = ContenidoWeb::latest('id')->first(); $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); $intentosRestantes = max(0, RateLimiter::remaining(md5('login-cliente-web' . $request->ip()), 5)); return view('auth.login-cliente', [ 'versionSitio' => $versionSitio, 'intentosRestantes' => $intentosRestantes, 'intentosMaximos' => 5, ]); }); Route::get('/login/personal', function (Request $request) { $contenidoWeb = ContenidoWeb::latest('id')->first(); $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); $intentosRestantes = max(0, RateLimiter::remaining(md5('login-personal-web' . $request->ip()), 5)); return view('auth.login-personal', [ 'versionSitio' => $versionSitio, 'intentosRestantes' => $intentosRestantes, 'intentosMaximos' => 5, ]); }); Route::post('/login/cliente', [AuthController::class, 'loginClienteWeb'])->middleware('throttle:login-cliente-web'); Route::post('/login/personal', [AuthController::class, 'loginPersonalWeb'])->middleware('throttle:login-personal-web'); Route::get('/cliente/recuperar-credenciales', function (Request $request) { $intentosRestantes = max(0, RateLimiter::remaining(md5('recuperar-cliente' . $request->ip()), 5)); return view('auth.recuperar-credenciales', [ 'intentosRestantes' => $intentosRestantes, 'intentosMaximos' => 5, ]); }); Route::post('/cliente/recuperar-credenciales', function (Request $request) use ($honeypotValido, $registrarLogSeguridadDirecto, $hashearTokenRecuperacion) { $request->validate([ 'correo' => ['required', 'email', 'max:255'], 'website' => ['nullable', 'string', 'max:255'], ]); if (!$honeypotValido($request)) { return back()->withErrors(['recuperar_error' => 'No se pudo procesar la solicitud.'])->withInput($request->except(['website'])); } $correo = trim((string) $request->input('correo')); $credencial = CredencialCliente::where('correo', $correo)->first(); if ($credencial) { $token = Str::random(64); $credencial->update([ 'reset_token' => $hashearTokenRecuperacion($token), 'reset_expira_en' => now()->addMinutes(30), ]); $registrarLogSeguridadDirecto( (int) ($credencial->cliente?->persona_id ?? 0), 'CLIENTE', (string) $request->ip(), 'Solicitud cambio de contraseña', 'El cliente ID ' . (int) ($credencial->cliente?->id ?? 0) . ' solicitó un cambio de contraseña.' ); $link = url('/cliente/recuperar-credenciales/' . $token); $cuerpo = "Hola,\n\nRecibimos una solicitud para restablecer tu contraseña.\n" . "Hacé clic en el siguiente enlace para crear una nueva (válido por 30 minutos):\n\n" . $link . "\n\n" . "Si no solicitaste este cambio, ignorá este mensaje."; try { Mail::raw($cuerpo, function ($message) use ($correo): void { $message->to($correo)->subject('Restablecer contraseña - Abogadas del Litoral'); }); } catch (\Throwable $e) { logger()->warning('No se pudo enviar correo de recuperación.', [ 'correo' => $correo, 'error' => $e->getMessage(), ]); } } // Siempre mostrar el mismo mensaje para no revelar si el correo existe return back()->with('recuperar_success', 'Si el correo está registrado, recibirás un enlace para restablecer tu contraseña en los próximos minutos.'); })->middleware('throttle:recuperar-cliente'); Route::get('/cliente/recuperar-credenciales/{token}', function (string $token) use ($buscarCredencialClientePorResetToken) { $credencial = $buscarCredencialClientePorResetToken($token); if (!$credencial) { return redirect('/cliente/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } return view('auth.nueva-contrasena', ['token' => $token]); }); Route::post('/cliente/recuperar-credenciales/{token}', function (Request $request, string $token) use ($registrarLogSeguridadDirecto, $buscarCredencialClientePorResetToken) { $credencial = $buscarCredencialClientePorResetToken($token); if (!$credencial) { return redirect('/cliente/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } $request->validate([ 'contra' => ['required', 'string', 'min:6', 'max:30', 'confirmed'], ]); $credencial->update([ 'contra' => Hash::make((string) $request->input('contra')), 'reset_token' => null, 'reset_expira_en' => null, ]); $registrarLogSeguridadDirecto( (int) ($credencial->cliente?->persona_id ?? 0), 'CLIENTE', (string) $request->ip(), 'Cambio de contraseña exitoso', 'El cliente ID ' . (int) ($credencial->cliente?->id ?? 0) . ' cambió su contraseña correctamente.' ); return redirect('/login/cliente') ->with('login_success', 'Tu contraseña se actualizó correctamente. Ya podés iniciar sesión.'); }); Route::get('/personal/recuperar-credenciales', function (Request $request) { $intentosRestantes = max(0, RateLimiter::remaining(md5('recuperar-personal' . $request->ip()), 5)); $profesiones = Profesion::query()->orderBy('titulo')->get(['id', 'titulo']); return view('auth.recuperar-credenciales-personal', [ 'intentosRestantes' => $intentosRestantes, 'intentosMaximos' => 5, 'profesiones' => $profesiones, ]); }); Route::post('/personal/recuperar-credenciales', function (Request $request) use ($honeypotValido, $registrarLogSeguridadDirecto, $hashearTokenRecuperacion) { $request->validate([ 'dni' => ['required', 'string', 'max:20', 'regex:/^[0-9]+$/'], 'matricula' => ['required', 'string', 'max:100'], 'profesion_id' => ['required', 'integer', 'exists:profesiones,id'], 'correo' => ['required', 'email', 'max:255'], 'website' => ['nullable', 'string', 'max:255'], ]); if (!$honeypotValido($request)) { return back()->withErrors(['recuperar_error' => 'No se pudo procesar la solicitud.'])->withInput($request->except(['website'])); } $dni = trim((string) $request->input('dni')); $matricula = trim((string) $request->input('matricula')); $profesionId = (int) $request->input('profesion_id'); $correo = trim((string) $request->input('correo')); $profesional = Profesional::query() ->with('credencialProfesional') ->where('dni', $dni) ->where('matricula', $matricula) ->where('correo', $correo) ->where(function ($query) use ($profesionId): void { $query->where('profesion_id', $profesionId) ->orWhereHas('profesiones', function ($q) use ($profesionId): void { $q->where('profesiones.id', $profesionId); }); }) ->first(); if ($profesional?->credencialProfesional) { $token = Str::random(64); $profesional->credencialProfesional->update([ 'reset_token' => $hashearTokenRecuperacion($token), 'reset_expira_en' => now()->addMinutes(30), ]); $registrarLogSeguridadDirecto( (int) ($profesional->persona_id ?? 0), 'PROFESIONAL', (string) $request->ip(), 'Solicitud cambio de contraseña', 'El profesional ID ' . (int) $profesional->id . ' solicitó un cambio de contraseña.' ); $link = url('/personal/recuperar-credenciales/' . $token); $cuerpo = "Hola,\n\nRecibimos una solicitud para restablecer tu contraseña.\n" . "Hacé clic en el siguiente enlace para crear una nueva (válido por 30 minutos):\n\n" . $link . "\n\n" . "Si no solicitaste este cambio, ignorá este mensaje."; try { Mail::raw($cuerpo, function ($message) use ($correo): void { $message->to($correo)->subject('Restablecer contraseña - Personal - Abogadas del Litoral'); }); } catch (\Throwable $e) { logger()->warning('No se pudo enviar correo de recuperación para personal.', [ 'correo' => $correo, 'error' => $e->getMessage(), ]); } } return back()->with('recuperar_success', 'Si los datos coinciden con un profesional registrado, recibirás un enlace para restablecer tu contraseña en los próximos minutos.'); })->middleware('throttle:recuperar-personal'); Route::get('/personal/recuperar-credenciales/{token}', function (string $token) use ($buscarCredencialProfesionalPorResetToken) { $credencial = $buscarCredencialProfesionalPorResetToken($token); if (!$credencial) { return redirect('/personal/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } return view('auth.nueva-contrasena-personal', ['token' => $token]); }); Route::post('/personal/recuperar-credenciales/{token}', function (Request $request, string $token) use ($registrarLogSeguridadDirecto, $buscarCredencialProfesionalPorResetToken) { $credencial = $buscarCredencialProfesionalPorResetToken($token); if (!$credencial) { return redirect('/personal/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } $request->validate([ 'contra' => ['required', 'string', 'min:6', 'max:30', 'confirmed'], ]); $credencial->update([ 'contra' => Hash::make((string) $request->input('contra')), 'reset_token' => null, 'reset_expira_en' => null, ]); $registrarLogSeguridadDirecto( (int) ($credencial->profesional?->persona_id ?? $credencial->administrador?->persona_id ?? 0), strtoupper((string) ($credencial->rol ?? 'PROFESIONAL')) === 'ADMIN' ? 'ADMIN' : 'PROFESIONAL', (string) $request->ip(), 'Cambio de contraseña exitoso', 'La cuenta personal ID ' . (int) $credencial->id . ' cambió su contraseña correctamente.' ); return redirect('/login/personal') ->with('login_success', 'Tu contraseña se actualizó correctamente. Ya podés iniciar sesión.'); }); Route::get('/admin/recuperar-credenciales', function (Request $request) { $intentosRestantes = max(0, RateLimiter::remaining(md5('recuperar-admin' . $request->ip()), 5)); return view('auth.recuperar-credenciales-admin', [ 'intentosRestantes' => $intentosRestantes, 'intentosMaximos' => 5, ]); }); Route::get('/admin/recuperar-credenciales/pregunta', function (Request $request) { $correo = trim((string) $request->query('correo', '')); if ($correo === '') { return response()->json([ 'encontrada' => false, 'pregunta' => '', ]); } $admin = Administrador::query() ->with('credencial') ->where('correo', $correo) ->first(); if (!$admin || strtoupper((string) ($admin->credencial?->rol ?? '')) !== 'ADMIN') { return response()->json([ 'encontrada' => false, 'pregunta' => '', ]); } $preguntaGuardada = trim((string) ($admin->pregunta_secreta_hash ?? '')); $preguntaEsHash = Str::startsWith($preguntaGuardada, ['$2y$', '$2a$', '$2b$']); return response()->json([ 'encontrada' => !$preguntaEsHash && $preguntaGuardada !== '', 'pregunta' => !$preguntaEsHash ? $preguntaGuardada : '', ]); })->middleware('throttle:recuperar-admin-pregunta'); Route::post('/admin/recuperar-credenciales', function (Request $request) use ($honeypotValido, $registrarLogSeguridadDirecto, $hashearTokenRecuperacion) { $request->validate([ 'celular' => ['required', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'correo' => ['required', 'email', 'max:255'], 'pregunta_secreta' => ['nullable', 'string', 'max:255'], 'respuesta_secreta' => ['required', 'string', 'max:255'], 'website' => ['nullable', 'string', 'max:255'], ]); if (!$honeypotValido($request)) { return back()->withErrors(['recuperar_error' => 'No se pudo procesar la solicitud.'])->withInput($request->except(['website'])); } $correo = trim((string) $request->input('correo')); $nombre = mb_strtolower(trim((string) $request->input('nombre'))); $apellido = mb_strtolower(trim((string) $request->input('apellido'))); $celularNormalizado = preg_replace('/\D+/', '', (string) $request->input('celular')); $respuestaSecreta = mb_strtolower(trim((string) $request->input('respuesta_secreta'))); $admin = Administrador::query() ->with(['persona.telefonos', 'credencial']) ->where('correo', $correo) ->first(); if ($admin && $admin->credencial && strtoupper((string) ($admin->credencial->rol ?? '')) === 'ADMIN') { $nombreAdmin = mb_strtolower(trim((string) ($admin->persona?->nombre ?? ''))); $apellidoAdmin = mb_strtolower(trim((string) ($admin->persona?->apellido ?? ''))); $celularAdmin = preg_replace('/\D+/', '', (string) ($admin->persona?->telefonos?->first()?->telefono ?? '')); $preguntaGuardada = trim((string) ($admin->pregunta_secreta_hash ?? '')); $respuestaHash = (string) ($admin->respuesta_secreta_hash ?? ''); $coincide = $nombre !== '' && $apellido !== '' && $celularNormalizado !== '' && $nombre === $nombreAdmin && $apellido === $apellidoAdmin && $celularNormalizado === $celularAdmin && $preguntaGuardada !== '' && $respuestaHash !== '' && Hash::check($respuestaSecreta, $respuestaHash); if ($coincide) { $token = Str::random(64); $admin->credencial->update([ 'reset_token' => $hashearTokenRecuperacion($token), 'reset_expira_en' => now()->addMinutes(30), ]); $registrarLogSeguridadDirecto( (int) ($admin->persona_id ?? 0), 'ADMIN', (string) $request->ip(), 'Solicitud cambio de contraseña', 'La administradora ID ' . (int) $admin->id . ' solicitó un cambio de contraseña.' ); $link = url('/admin/recuperar-credenciales/' . $token); $cuerpo = "Hola,\n\nRecibimos una solicitud para restablecer tu contraseña de administrador.\n" . "Hacé clic en el siguiente enlace para crear una nueva (válido por 30 minutos):\n\n" . $link . "\n\n" . "Si no solicitaste este cambio, ignorá este mensaje."; try { Mail::raw($cuerpo, function ($message) use ($correo): void { $message->to($correo)->subject('Restablecer contraseña - Administrador - Abogadas del Litoral'); }); } catch (\Throwable $e) { logger()->warning('No se pudo enviar correo de recuperación para administrador.', [ 'correo' => $correo, 'error' => $e->getMessage(), ]); } } } return back()->with('recuperar_success', 'Si los datos coinciden con un administrador registrado, recibirás un enlace para restablecer tu contraseña en los próximos minutos.'); })->middleware('throttle:recuperar-admin'); Route::get('/admin/recuperar-credenciales/{token}', function (string $token) use ($buscarCredencialProfesionalPorResetToken) { $credencial = $buscarCredencialProfesionalPorResetToken($token, 'ADMIN'); if (!$credencial) { return redirect('/admin/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } return view('auth.nueva-contrasena-admin', [ 'token' => $token, 'usuarioActual' => (string) ($credencial->usuario ?? ''), ]); }); Route::post('/admin/recuperar-credenciales/{token}', function (Request $request, string $token) use ($registrarLogSeguridadDirecto, $buscarCredencialProfesionalPorResetToken) { $credencial = $buscarCredencialProfesionalPorResetToken($token, 'ADMIN'); if (!$credencial) { return redirect('/admin/recuperar-credenciales') ->with('recuperar_error', 'El enlace es inválido o ya expiró. Solicitá uno nuevo.'); } $request->validate([ 'usuario' => ['required', 'string', 'max:100', Rule::unique('credencialesprofesionales', 'usuario')->ignore((int) $credencial->id)], 'contra' => ['required', 'string', 'min:6', 'max:30', 'confirmed'], ]); $usuarioNuevo = trim((string) $request->input('usuario')); $credencial->update([ 'usuario' => $usuarioNuevo, 'contra' => Hash::make((string) $request->input('contra')), 'reset_token' => null, 'reset_expira_en' => null, ]); $registrarLogSeguridadDirecto( (int) ($credencial->administrador?->persona_id ?? 0), 'ADMIN', (string) $request->ip(), 'Cambio de credenciales exitoso', 'La cuenta administradora ID ' . (int) $credencial->id . ' actualizó su usuario y contraseña correctamente.' ); return redirect('/login/personal') ->with('login_success', 'Tu usuario y contraseña de administrador se actualizaron correctamente.'); }); Route::post('/profesional/asignar-turno-directo', function (Request $request) use ($obtenerAdvertenciasTurno, $verificarConflictoTurno, $enviarCorreoTurno, $registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->with('profesiones') ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $profesionIdsProfesional = collect([(int) ($profesional->profesion_id ?? 0)]) ->merge($profesional->profesiones->pluck('id')->map(fn ($id) => (int) $id)) ->filter(fn ($id) => (int) $id > 0) ->unique() ->values() ->all(); $validated = $request->validate([ 'fecha_turno' => ['required', 'date', 'after_or_equal:today'], 'hora_turno' => ['required', 'date_format:H:i'], 'nombrecompleto' => ['required', 'string', 'max:200'], 'correo' => ['required', 'email', 'max:255'], 'celular' => ['required', 'string', 'regex:/^[0-9]{8,15}$/'], 'descripcion' => ['nullable', 'string', 'max:1000'], 'servicio_id' => [ 'required', Rule::exists('servicios', 'id')->where(function ($query) use ($profesionIdsProfesional) { $query->where('estado', 'activo'); if (!empty($profesionIdsProfesional)) { $query->whereIn('profesion_id', $profesionIdsProfesional); } }), ], 'modalidad_id' => ['required', 'exists:modalidades,id'], 'omitir_restricciones' => ['nullable', 'boolean'], ]); $inicio = \Illuminate\Support\Carbon::parse($validated['fecha_turno'] . ' ' . $validated['hora_turno'] . ':00'); $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => (int) $profesional->id], ['estado' => 'activo', 'duracionturno' => 30] ); $duracion = (int) ($agenda->duracionturno ?? 30); $advertencias = $obtenerAdvertenciasTurno((int) $agenda->id, $duracion, $inicio); $errorDisponibilidad = $verificarConflictoTurno((int) $agenda->id, $duracion, $inicio); if ($errorDisponibilidad !== null) { return redirect('/profesional/dashboard') ->with('turno_directo_error', 'Ese dia y horario no se encuentra disponible: ' . $errorDisponibilidad) ->withInput(); } if (!empty($advertencias) && !((bool) ($validated['omitir_restricciones'] ?? false))) { return redirect('/profesional/dashboard') ->with('turno_directo_warning', [ 'advertencias' => $advertencias, 'inputs' => [ 'fecha_turno' => $validated['fecha_turno'], 'hora_turno' => $validated['hora_turno'], 'nombrecompleto' => $validated['nombrecompleto'], 'correo' => $validated['correo'], 'celular' => $validated['celular'], 'descripcion' => $validated['descripcion'] ?? '', 'servicio_id' => $validated['servicio_id'], 'modalidad_id' => $validated['modalidad_id'], ], ]) ->withInput(); } $estadoConfirmadoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['confirmado']) ->value('id') ?? 1); Turno::create([ 'inicio' => $inicio, 'correo' => $validated['correo'], 'celular' => $validated['celular'], 'nombrecompleto' => $validated['nombrecompleto'], 'descripcion' => trim((string) ($validated['descripcion'] ?? '')) !== '' ? trim((string) $validated['descripcion']) : 'Turno asignado manualmente desde la agenda.', 'cliente_id' => null, 'estadoturno_id' => $estadoConfirmadoId, 'agenda_id' => (int) $agenda->id, 'profesional_id' => (int) $profesional->id, 'servicio_id' => (int) $validated['servicio_id'], 'modalidad_id' => (int) $validated['modalidad_id'], ]); $profesional->loadMissing('persona'); $nombreProfesional = trim((string) (($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? ''))); $servicioTitulo = (string) (Servicio::query()->where('id', (int) $validated['servicio_id'])->value('titulo') ?? 'Servicio'); $modalidadDescripcion = (string) (Modalidad::query()->where('id', (int) $validated['modalidad_id'])->value('descripcion') ?? 'Modalidad'); $enviarCorreoTurno( 'asignacion', (string) $validated['correo'], (string) $validated['nombrecompleto'], $inicio->copy(), $servicioTitulo, $modalidadDescripcion, $nombreProfesional ); $registrarLogSeguridadProfesional( $request, 'Asignó un turno', 'El profesional ID ' . (int) $profesional->id . ' asignó un turno para "' . $validated['nombrecompleto'] . '" el ' . $inicio->format('d/m/Y H:i') . '.' ); return redirect('/profesional/dashboard') ->with('turno_directo_success', 'El turno se asignó correctamente.'); }); Route::post('/profesional/turnos/{turno}/cancelar', function (Request $request, Turno $turno) use ($enviarCorreoTurno, $registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } if ((int) $turno->profesional_id !== (int) $profesional->id) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'No podés cancelar un turno que no pertenece a tu agenda.'); } $estadoCanceladoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['cancelado']) ->value('id') ?? 0); if ($estadoCanceladoId <= 0) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'No se encontró el estado Cancelado.'); } if ((int) $turno->estadoturno_id === $estadoCanceladoId) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'El turno seleccionado ya estaba cancelado.'); } $turno->update([ 'estadoturno_id' => $estadoCanceladoId, ]); $turno->loadMissing(['servicio', 'modalidad']); $profesional->loadMissing('persona'); $nombreProfesional = trim((string) (($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? ''))); $correoTurno = (string) ($turno->correo ?? ''); $nombreTurno = (string) ($turno->nombrecompleto ?? ''); $servicioTurno = (string) ($turno->servicio?->titulo ?? 'Servicio'); $modalidadTurno = (string) ($turno->modalidad?->descripcion ?? 'Modalidad'); $inicioTurno = $turno->inicio ? $turno->inicio->copy() : now(); $enviarCorreoTurno( 'cancelacion', $correoTurno, $nombreTurno, $inicioTurno, $servicioTurno, $modalidadTurno, $nombreProfesional ); $registrarLogSeguridadProfesional( $request, 'Canceló un turno', 'El profesional ID ' . (int) $profesional->id . ' canceló el turno ID ' . (int) $turno->id . ' de "' . $nombreTurno . '".' ); return redirect('/profesional/dashboard') ->with('profesional_action_success', 'El turno se canceló correctamente.'); }); Route::post('/profesional/turnos/{turno}/reprogramar', function (Request $request, Turno $turno) use ($obtenerAdvertenciasTurno, $verificarConflictoTurno, $enviarCorreoTurno, $registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } if ((int) $turno->profesional_id !== (int) $profesional->id) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'No podés reprogramar un turno que no pertenece a tu agenda.'); } $validated = $request->validate([ 'fecha_turno' => ['required', 'date', 'after_or_equal:today'], 'hora_turno' => ['required', 'date_format:H:i'], 'omitir_restricciones' => ['nullable', 'boolean'], ]); $estadoCanceladoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['cancelado']) ->value('id') ?? 0); if ($estadoCanceladoId > 0 && (int) $turno->estadoturno_id === $estadoCanceladoId) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'No podés reprogramar un turno cancelado.'); } $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => (int) $profesional->id], ['estado' => 'activo', 'duracionturno' => 30] ); $duracion = (int) ($agenda->duracionturno ?? 30); $inicio = \Illuminate\Support\Carbon::parse($validated['fecha_turno'] . ' ' . $validated['hora_turno'] . ':00'); $advertencias = $obtenerAdvertenciasTurno((int) $agenda->id, $duracion, $inicio); $errorDisponibilidad = $verificarConflictoTurno((int) $agenda->id, $duracion, $inicio, (int) $turno->id); if ($errorDisponibilidad !== null) { return redirect('/profesional/dashboard') ->with('profesional_action_error', 'No se pudo reprogramar el turno: ' . $errorDisponibilidad); } if (!empty($advertencias) && !((bool) ($validated['omitir_restricciones'] ?? false))) { return redirect('/profesional/dashboard') ->with('reprogramar_turno_warning', [ 'advertencias' => $advertencias, 'inputs' => [ 'fecha_turno' => $validated['fecha_turno'], 'hora_turno' => $validated['hora_turno'], ], 'turno_id' => (int) $turno->id, ]); } $estadoReprogramadoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['reprogramado']) ->value('id') ?? 0); $datosActualizar = [ 'inicio' => $inicio, 'agenda_id' => (int) $agenda->id, ]; if ($estadoReprogramadoId > 0) { $datosActualizar['estadoturno_id'] = $estadoReprogramadoId; } $turno->update($datosActualizar); $turno->loadMissing(['servicio', 'modalidad']); $profesional->loadMissing('persona'); $nombreProfesional = trim((string) (($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? ''))); $correoTurno = (string) ($turno->correo ?? ''); $nombreTurno = (string) ($turno->nombrecompleto ?? ''); $servicioTurno = (string) ($turno->servicio?->titulo ?? 'Servicio'); $modalidadTurno = (string) ($turno->modalidad?->descripcion ?? 'Modalidad'); $enviarCorreoTurno( 'reprogramacion', $correoTurno, $nombreTurno, $inicio->copy(), $servicioTurno, $modalidadTurno, $nombreProfesional ); $registrarLogSeguridadProfesional( $request, 'Reprogramó un turno', 'El profesional ID ' . (int) $profesional->id . ' reprogramó el turno ID ' . (int) $turno->id . ' de "' . $nombreTurno . '" para el ' . $inicio->format('d/m/Y H:i') . '.' ); return redirect('/profesional/dashboard') ->with('profesional_action_success', 'El turno se reprogramó correctamente.'); }); Route::get('/profesional/dashboard', function () { $credencialId = (int) session('personal_credencial_id', 0); $profesionalSesion = Profesional::query() ->with(['profesion', 'profesiones']) ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesionalSesion) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $contenidoWeb = ContenidoWeb::latest('id')->first(); $quienesSomos = $contenidoWeb?->quienessomos; $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); $profesionales = Profesional::with(['persona.Foto', 'profesion', 'profesiones']) ->where('baja_id', 1) ->get(); $profesionIdsProfesional = collect([(int) ($profesionalSesion->profesion_id ?? 0)]) ->merge($profesionalSesion->profesiones->pluck('id')->map(fn ($id) => (int) $id)) ->filter(fn ($id) => (int) $id > 0) ->unique() ->values(); $servicios = Servicio::with('foto') ->where('estado', 'activo') ->where('visibleenweb', 'si') ->when($profesionIdsProfesional->isNotEmpty(), function ($query) use ($profesionIdsProfesional) { $query->whereIn('profesion_id', $profesionIdsProfesional->all()); }) ->orderBy('titulo') ->get(); $profesiones = Profesion::query() ->where('visible_en_formulario', true) ->orderBy('titulo') ->get(); $modalidades = Modalidad::query() ->orderBy('descripcion') ->get(); $turnos = Turno::query() ->with(['servicio', 'modalidad', 'estadoTurno', 'cliente.persona.telefonos']) ->where('profesional_id', (int) $profesionalSesion->id) ->orderBy('inicio') ->get(); $agendaProfesional = Agenda::query() ->with(['diaDeAtencion.dias', 'diaDeAtencion.horariosAtenciones', 'diaDeAtencion.horariosRecesos', 'feriado', 'modoVacaciones']) ->where('profesional_id', (int) $profesionalSesion->id) ->first(); $numeroDiaSemana = [ 'domingo' => 0, 'lunes' => 1, 'martes' => 2, 'miercoles' => 3, 'miércoles' => 3, 'jueves' => 4, 'viernes' => 5, 'sabado' => 6, 'sábado' => 6, ]; $coloresEstado = [ 'confirmado' => '#198754', 'cancelado' => '#dc3545', 'reprogramado' => '#fd7e14', 'cliente ausente' => '#6c757d', 'cliente presente' => '#0d6efd', ]; $duracionTurnoVisual = (int) ($agendaProfesional?->duracionturno ?? 30); $eventosAgenda = $turnos ->filter(fn ($turno) => $turno->inicio) ->filter(function ($turno) { $estadoDescripcion = mb_strtolower(trim((string) ($turno->estadoTurno?->descripcion ?? ''))); return $estadoDescripcion !== 'cancelado'; }) ->map(function ($turno) use ($coloresEstado, $duracionTurnoVisual) { $estadoDescripcion = trim((string) ($turno->estadoTurno?->descripcion ?? 'Sin estado')); $estadoKey = mb_strtolower($estadoDescripcion); $color = $coloresEstado[$estadoKey] ?? '#0d6efd'; // Naranja para turnos asignados manualmente (sin cliente vinculado y estado confirmado) if ($estadoKey === 'confirmado' && $turno->cliente_id === null) { $color = '#fd7e14'; } $servicioTitulo = trim((string) ($turno->servicio?->titulo ?? 'Turno')); $nombreCompleto = trim((string) ($turno->nombrecompleto ?? 'Cliente')); $celularCliente = trim((string) ($turno->cliente?->persona?->telefonos?->first()?->telefono ?? '')); if ($celularCliente === '') { $celularCliente = trim((string) ($turno->celular ?? '')); } return [ 'id' => (int) $turno->id, 'title' => $servicioTitulo . ' - ' . $nombreCompleto, 'start' => $turno->inicio->format('Y-m-d\TH:i:s'), 'end' => $turno->inicio->copy()->addMinutes($duracionTurnoVisual)->format('Y-m-d\TH:i:s'), 'backgroundColor' => $color, 'borderColor' => $color, 'extendedProps' => [ 'prioridad' => 4, 'turnoId' => (int) $turno->id, 'cliente' => $nombreCompleto, 'celular' => $celularCliente !== '' ? $celularCliente : '-', 'correo' => (string) ($turno->correo ?? ''), 'descripcion' => (string) ($turno->descripcion ?? ''), 'servicio' => $servicioTitulo, 'modalidad' => (string) ($turno->modalidad?->descripcion ?? '-'), 'estado' => $estadoDescripcion, 'estadoKey' => $estadoKey, 'puedeCancelarse' => $estadoKey !== 'cancelado', 'puedeReprogramarse' => $estadoKey !== 'cancelado', ], ]; }) ->values(); $inicioRango = now()->startOfDay(); $finRango = now()->addDays(90)->endOfDay(); $eventosDisponibilidad = collect($agendaProfesional?->diaDeAtencion ?? []) ->flatMap(function ($diaAtencion) use ($numeroDiaSemana, $inicioRango, $finRango) { $diaDescripcion = mb_strtolower(trim((string) ($diaAtencion->dias?->descripcion ?? ''))); $diaSemana = $numeroDiaSemana[$diaDescripcion] ?? null; if ($diaSemana === null) { return []; } return collect($diaAtencion->horariosAtenciones ?? []) ->filter(fn ($horario) => $horario->horariocomienzo && $horario->horariofin) ->flatMap(function ($horario) use ($diaSemana, $diaAtencion, $inicioRango, $finRango) { $eventos = []; $cursor = $inicioRango->copy(); while ($cursor->lte($finRango)) { if ((int) $cursor->dayOfWeek === (int) $diaSemana) { $fecha = $cursor->format('Y-m-d'); $horaInicio = substr((string) $horario->horariocomienzo, 0, 8); $horaFin = substr((string) $horario->horariofin, 0, 8); $eventos[] = [ 'id' => 'disp-' . $diaAtencion->id . '-' . $horario->id . '-' . $fecha, 'title' => 'Disponibilidad', 'start' => $fecha . 'T' . $horaInicio, 'end' => $fecha . 'T' . $horaFin, 'display' => 'background', 'classNames' => ['evento-disponibilidad'], 'backgroundColor' => '#198754', 'borderColor' => '#198754', 'extendedProps' => [ 'prioridad' => 5, 'tipoEvento' => 'disponibilidad', 'estado' => 'Disponible', 'modalidad' => '-', 'servicio' => 'Horario de atención', 'cliente' => '-', 'correo' => '-', 'descripcion' => 'Franja horaria configurada en tu agenda.', ], ]; } $cursor->addDay(); } return $eventos; }); }) ->values(); $eventosReceso = collect($agendaProfesional?->diaDeAtencion ?? []) ->flatMap(function ($diaAtencion) use ($numeroDiaSemana, $inicioRango, $finRango) { $diaDescripcion = mb_strtolower(trim((string) ($diaAtencion->dias?->descripcion ?? ''))); $diaSemana = $numeroDiaSemana[$diaDescripcion] ?? null; if ($diaSemana === null) { return []; } return collect($diaAtencion->horariosRecesos ?? []) ->filter(fn ($receso) => $receso->comienzo && $receso->fin) ->flatMap(function ($receso) use ($diaSemana, $diaAtencion, $inicioRango, $finRango) { $eventos = []; $cursor = $inicioRango->copy(); while ($cursor->lte($finRango)) { if ((int) $cursor->dayOfWeek === (int) $diaSemana) { $fecha = $cursor->format('Y-m-d'); $horaInicio = substr((string) $receso->comienzo, 0, 8); $horaFin = substr((string) $receso->fin, 0, 8); $descripcionReceso = trim((string) ($receso->descripcion ?? '')); $tituloReceso = $descripcionReceso !== '' ? 'Receso: ' . $descripcionReceso : 'Receso'; $eventos[] = [ 'id' => 'receso-' . $diaAtencion->id . '-' . $receso->id . '-' . $fecha, 'title' => $tituloReceso, 'start' => $fecha . 'T' . $horaInicio, 'end' => $fecha . 'T' . $horaFin, 'display' => 'background', 'classNames' => ['evento-receso'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => [ 'prioridad' => 3, 'tipoEvento' => 'no_disponible', 'motivo' => 'Receso', 'estado' => 'No disponible', 'descripcion' => 'Franja horaria de receso configurada.', ], ]; } $cursor->addDay(); } return $eventos; }); }) ->values(); $eventosVacaciones = collect($agendaProfesional?->modoVacaciones ?? []) ->filter(fn ($vacacion) => $vacacion->inicio && $vacacion->fin) ->map(function ($vacacion) { $inicio = (string) $vacacion->inicio; $finExclusivo = \Illuminate\Support\Carbon::parse((string) $vacacion->fin)->addDay()->format('Y-m-d'); $descripcionVacacion = trim((string) ($vacacion->descripcion ?? '')); return [ 'id' => 'vacaciones-' . $vacacion->id, 'title' => $descripcionVacacion !== '' ? 'Vacaciones: ' . $descripcionVacacion : 'Vacaciones', 'start' => $inicio, 'end' => $finExclusivo, 'allDay' => true, 'display' => 'background', 'classNames' => ['evento-vacaciones'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => [ 'prioridad' => 2, 'tipoEvento' => 'no_disponible', 'motivo' => 'Vacaciones', 'estado' => 'No disponible', 'descripcion' => (string) ($vacacion->descripcion ?: 'Vacaciones'), ], ]; }) ->values(); $eventosFeriados = collect($agendaProfesional?->feriado ?? []) ->filter(fn ($feriado) => $feriado->fecha) ->map(function ($feriado) { $fecha = (string) $feriado->fecha; $finExclusivo = \Illuminate\Support\Carbon::parse($fecha)->addDay()->format('Y-m-d'); $descripcionFeriado = trim((string) ($feriado->descripcion ?? '')); return [ 'id' => 'feriado-' . $feriado->id, 'title' => $descripcionFeriado !== '' ? 'Feriado: ' . $descripcionFeriado : 'Feriado', 'start' => $fecha, 'end' => $finExclusivo, 'allDay' => true, 'display' => 'background', 'classNames' => ['evento-feriado'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => [ 'prioridad' => 1, 'tipoEvento' => 'no_disponible', 'motivo' => 'Feriado', 'estado' => 'No disponible', 'descripcion' => (string) ($feriado->descripcion ?: 'Feriado'), ], ]; }) ->values(); $duracionTurnoAgenda = (int) ($agendaProfesional?->duracionturno ?? 30); $rangosTurnosOcupados = $turnos ->filter(fn ($turno) => $turno->inicio) ->filter(function ($turno) { $estadoDescripcion = mb_strtolower(trim((string) ($turno->estadoTurno?->descripcion ?? ''))); return $estadoDescripcion !== 'cancelado'; }) ->map(function ($turno) use ($duracionTurnoAgenda) { $inicio = \Illuminate\Support\Carbon::parse((string) $turno->inicio); return [ 'inicio' => $inicio->copy(), 'fin' => $inicio->copy()->addMinutes($duracionTurnoAgenda), ]; }) ->values(); $rangosNoDisponibles = collect() ->concat($rangosTurnosOcupados) ->concat($eventosReceso) ->concat($eventosVacaciones) ->concat($eventosFeriados) ->map(function ($evento) { $inicio = isset($evento['start']) ? \Illuminate\Support\Carbon::parse((string) $evento['start']) : null; $fin = isset($evento['end']) ? \Illuminate\Support\Carbon::parse((string) $evento['end']) : null; if (isset($evento['inicio'], $evento['fin'])) { return ['inicio' => $evento['inicio']->copy(), 'fin' => $evento['fin']->copy()]; } if (!$inicio || !$fin) { return null; } return ['inicio' => $inicio, 'fin' => $fin]; }) ->filter() ->values(); $eventosDisponibilidad = $eventosDisponibilidad ->flatMap(function ($evento) use ($rangosNoDisponibles) { if (!isset($evento['start'], $evento['end'])) { return [$evento]; } $inicioEvento = \Illuminate\Support\Carbon::parse((string) $evento['start']); $finEvento = \Illuminate\Support\Carbon::parse((string) $evento['end']); $rangosSolapados = $rangosNoDisponibles ->filter(function ($rango) use ($inicioEvento, $finEvento) { return $inicioEvento->lt($rango['fin']) && $finEvento->gt($rango['inicio']); }) ->map(function ($rango) use ($inicioEvento, $finEvento) { $inicio = $rango['inicio']->copy()->greaterThan($inicioEvento) ? $rango['inicio']->copy() : $inicioEvento->copy(); $fin = $rango['fin']->copy()->lessThan($finEvento) ? $rango['fin']->copy() : $finEvento->copy(); return ['inicio' => $inicio, 'fin' => $fin]; }) ->values() ->all(); if (empty($rangosSolapados)) { return [$evento]; } usort($rangosSolapados, function ($a, $b) { if ($a['inicio']->equalTo($b['inicio'])) { return 0; } return $a['inicio']->lessThan($b['inicio']) ? -1 : 1; }); $segmentos = [ ['inicio' => $inicioEvento->copy(), 'fin' => $finEvento->copy()], ]; foreach ($rangosSolapados as $rango) { $nuevosSegmentos = []; foreach ($segmentos as $segmento) { if ($segmento['fin']->lessThanOrEqualTo($rango['inicio']) || $segmento['inicio']->greaterThanOrEqualTo($rango['fin'])) { $nuevosSegmentos[] = $segmento; continue; } if ($segmento['inicio']->lt($rango['inicio'])) { $nuevosSegmentos[] = [ 'inicio' => $segmento['inicio']->copy(), 'fin' => $rango['inicio']->copy()->subSecond(), ]; } if ($segmento['fin']->gt($rango['fin'])) { $nuevosSegmentos[] = [ 'inicio' => $rango['fin']->copy()->addSecond(), 'fin' => $segmento['fin']->copy(), ]; } } $segmentos = $nuevosSegmentos; } return collect($segmentos) ->filter(fn ($segmento) => $segmento['inicio']->lt($segmento['fin'])) ->values() ->map(function ($segmento, $index) use ($evento) { $eventoSegmentado = $evento; $eventoSegmentado['id'] = (string) ($evento['id'] ?? 'disp') . '-seg-' . $index; $eventoSegmentado['start'] = $segmento['inicio']->format('Y-m-d\TH:i:s'); $eventoSegmentado['end'] = $segmento['fin']->format('Y-m-d\TH:i:s'); return $eventoSegmentado; }) ->all(); }) ->values(); $eventosAgenda = collect() ->concat($eventosFeriados) ->concat($eventosVacaciones) ->concat($eventosReceso) ->concat($eventosAgenda) ->concat($eventosDisponibilidad) ->values(); $turnosNoCancelados = $turnos->filter(function ($turno) { if (!$turno->inicio) { return false; } $estadoDescripcion = mb_strtolower(trim((string) ($turno->estadoTurno?->descripcion ?? ''))); return $estadoDescripcion !== 'cancelado'; }); return view('profesional.dashboard', [ 'quienesSomos' => $quienesSomos, 'profesionales' => $profesionales, 'servicios' => $servicios, 'profesiones' => $profesiones, 'modalidades' => $modalidades, 'eventosAgenda' => $eventosAgenda, 'cantidadTurnosHoy' => $turnosNoCancelados ->filter(fn ($turno) => $turno->inicio && $turno->inicio->isToday()) ->count(), 'cantidadTurnosFuturos' => $turnosNoCancelados ->filter(fn ($turno) => $turno->inicio && $turno->inicio->greaterThan(now()->endOfDay())) ->count(), ]); }); Route::get('/profesional/ayuda', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesionalSesionId = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->value('id'); if (!$profesionalSesionId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } return view('profesional.ayuda'); }); Route::get('/profesional/clientes', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesionalSesionId = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->value('id'); if (!$profesionalSesionId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $filtroTexto = trim((string) $request->query('q', '')); $filtroEstado = trim((string) $request->query('estado', '')); $clientes = Cliente::with('persona') ->whereHas('profesionales', fn ($q) => $q->where('profesionales.id', (int) $profesionalSesionId)) ->when($filtroTexto !== '', function ($query) use ($filtroTexto) { $query->where(function ($subQuery) use ($filtroTexto): void { $subQuery->where('dni', 'like', '%' . $filtroTexto . '%') ->orWhere('correo', 'like', '%' . $filtroTexto . '%') ->orWhereHas('persona', function ($qPersona) use ($filtroTexto): void { $qPersona->where('nombre', 'like', '%' . $filtroTexto . '%') ->orWhere('apellido', 'like', '%' . $filtroTexto . '%') ->orWhere('dni', 'like', '%' . $filtroTexto . '%'); }); }); }) ->when($filtroEstado === 'activos', fn ($query) => $query->where('baja_id', 1)) ->when($filtroEstado === 'inactivos', fn ($query) => $query->where('baja_id', '!=', 1)) ->orderBy('id') ->paginate(10) ->withQueryString(); return view('profesional.mis-clientes', [ 'clientes' => $clientes, ]); }); Route::get('/profesional/mis-datos', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::with(['persona.Foto', 'profesion', 'credencialProfesional']) ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } return view('profesional.mis-datos', [ 'profesional' => $profesional, ]); }); Route::get('/profesional/configurar-agenda', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => (int) $profesional->id], ['estado' => 'activo', 'duracionturno' => 30] ); $diasCatalogo = Dia::query()->orderBy('id')->get(['id', 'descripcion']); $diasAtencion = DiaDeAtencion::query() ->with(['dias', 'horariosAtenciones', 'horariosRecesos']) ->where('agenda_id', (int) $agenda->id) ->get(); $bloquesAtencion = $diasAtencion ->flatMap(function ($diaAtencion) { return $diaAtencion->horariosAtenciones->map(function ($horario) use ($diaAtencion) { return [ 'dia_id' => (int) ($diaAtencion->dia_id ?? 0), 'horariocomienzo' => substr((string) $horario->horariocomienzo, 0, 5), 'horariofin' => substr((string) $horario->horariofin, 0, 5), 'tipo' => strtoupper((string) $horario->tipo), ]; }); }) ->values(); $bloquesReceso = $diasAtencion ->flatMap(function ($diaAtencion) { return $diaAtencion->horariosRecesos->map(function ($receso) use ($diaAtencion) { return [ 'dia_id' => (int) ($diaAtencion->dia_id ?? 0), 'comienzo' => substr((string) $receso->comienzo, 0, 5), 'fin' => substr((string) $receso->fin, 0, 5), ]; }); }) ->values(); $vacaciones = ModoVacaciones::query() ->where('agenda_id', (int) $agenda->id) ->orderBy('inicio') ->get(['inicio', 'fin', 'descripcion']) ->map(fn ($v) => [ 'inicio' => (string) $v->inicio, 'fin' => (string) $v->fin, 'descripcion' => (string) $v->descripcion, ]) ->values(); $feriados = Feriado::query() ->where('agenda_id', (int) $agenda->id) ->orderBy('fecha') ->get(['fecha', 'descripcion']) ->map(fn ($f) => [ 'fecha' => (string) $f->fecha, 'descripcion' => (string) $f->descripcion, ]) ->values(); return view('profesional.configurar-agenda', [ 'diasCatalogo' => $diasCatalogo, 'bloquesAtencion' => $bloquesAtencion, 'bloquesReceso' => $bloquesReceso, 'vacaciones' => $vacaciones, 'feriados' => $feriados, 'duracionTurno' => (int) ($agenda->duracionturno ?? 30), ]); }); Route::post('/profesional/configurar-agenda', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => (int) $profesional->id], ['estado' => 'activo', 'duracionturno' => 30] ); $duracionTurno = (int) $request->input('duracionturno', $agenda->duracionturno ?? 30); $diaIdsValidos = Dia::query()->pluck('id')->map(fn ($id) => (int) $id)->all(); $diaIdsLookup = array_flip($diaIdsValidos); $diasInput = collect($request->input('dias', [])) ->map(function ($fila) { return [ 'dia_id' => (int) ($fila['dia_id'] ?? 0), 'horariocomienzo' => trim((string) ($fila['horariocomienzo'] ?? '')), 'horariofin' => trim((string) ($fila['horariofin'] ?? '')), 'tipo' => strtoupper(trim((string) ($fila['tipo'] ?? ''))), ]; }) ->filter(fn ($fila) => $fila['dia_id'] > 0 || $fila['horariocomienzo'] !== '' || $fila['horariofin'] !== '' || $fila['tipo'] !== '') ->values(); $recesosInput = collect($request->input('recesos', [])) ->map(function ($fila) { return [ 'dia_id' => (int) ($fila['dia_id'] ?? 0), 'comienzo' => trim((string) ($fila['comienzo'] ?? '')), 'fin' => trim((string) ($fila['fin'] ?? '')), ]; }) ->filter(fn ($fila) => $fila['dia_id'] > 0 || $fila['comienzo'] !== '' || $fila['fin'] !== '') ->values(); $vacacionesInput = collect($request->input('vacaciones', [])) ->map(function ($fila) { return [ 'inicio' => trim((string) ($fila['inicio'] ?? '')), 'fin' => trim((string) ($fila['fin'] ?? '')), 'descripcion' => trim((string) ($fila['descripcion'] ?? '')), ]; }) ->filter(fn ($fila) => $fila['inicio'] !== '' || $fila['fin'] !== '' || $fila['descripcion'] !== '') ->values(); $feriadosInput = collect($request->input('feriados', [])) ->map(function ($fila) { return [ 'fecha' => trim((string) ($fila['fecha'] ?? '')), 'descripcion' => trim((string) ($fila['descripcion'] ?? '')), ]; }) ->filter(fn ($fila) => $fila['fecha'] !== '' || $fila['descripcion'] !== '') ->values(); $errores = []; if ($duracionTurno < 10 || $duracionTurno > 180 || $duracionTurno % 5 !== 0) { $errores['duracionturno'] = 'La duración del turno debe ser un número entre 10 y 180 minutos, en intervalos de 5.'; } if ($diasInput->count() > 10) { $errores['dias'] = 'Solo puedes cargar hasta 10 horarios de atención.'; } $cantidadAM = $diasInput->where('tipo', 'AM')->count(); $cantidadPM = $diasInput->where('tipo', 'PM')->count(); if ($cantidadAM > 5) { $errores['dias_am'] = 'Solo puedes cargar hasta 5 horarios de tipo AM.'; } if ($cantidadPM > 5) { $errores['dias_pm'] = 'Solo puedes cargar hasta 5 horarios de tipo PM.'; } foreach ($diasInput as $index => $fila) { if (!isset($diaIdsLookup[$fila['dia_id']])) { $errores['dias.' . $index . '.dia_id'] = 'El día seleccionado no es válido.'; } if (!in_array($fila['tipo'], ['AM', 'PM'], true)) { $errores['dias.' . $index . '.tipo'] = 'El tipo debe ser AM o PM.'; } if ($fila['horariocomienzo'] === '' || $fila['horariofin'] === '') { $errores['dias.' . $index . '.horario'] = 'Cada horario de atención debe tener inicio y fin.'; } elseif ($fila['horariocomienzo'] >= $fila['horariofin']) { $errores['dias.' . $index . '.rango'] = 'El horario de inicio debe ser menor al de fin.'; } } foreach ($recesosInput as $index => $fila) { if (!isset($diaIdsLookup[$fila['dia_id']])) { $errores['recesos.' . $index . '.dia_id'] = 'El día del receso no es válido.'; } if ($fila['comienzo'] === '' || $fila['fin'] === '') { $errores['recesos.' . $index . '.horario'] = 'Cada receso debe tener horario de inicio y fin.'; } elseif ($fila['comienzo'] >= $fila['fin']) { $errores['recesos.' . $index . '.rango'] = 'El receso debe tener inicio menor que fin.'; } } foreach ($vacacionesInput as $index => $fila) { if ($fila['inicio'] === '' || $fila['fin'] === '') { $errores['vacaciones.' . $index . '.fechas'] = 'Cada vacaciones debe tener fecha de inicio y fin.'; continue; } if ($fila['inicio'] > $fila['fin']) { $errores['vacaciones.' . $index . '.rango'] = 'La fecha de inicio de vacaciones no puede ser mayor al fin.'; } } foreach ($feriadosInput as $index => $fila) { if ($fila['fecha'] === '') { $errores['feriados.' . $index . '.fecha'] = 'Cada feriado debe tener fecha.'; } } if (!empty($errores)) { return back()->withErrors($errores)->withInput(); } DB::transaction(function () use ($agenda, $duracionTurno, $diasInput, $recesosInput, $vacacionesInput, $feriadosInput): void { $agenda->update([ 'duracionturno' => $duracionTurno, ]); $diasExistentes = DiaDeAtencion::query() ->where('agenda_id', (int) $agenda->id) ->pluck('id') ->map(fn ($id) => (int) $id) ->all(); if (!empty($diasExistentes)) { HorarioDeAtencion::query()->whereIn('diadeatencion_id', $diasExistentes)->delete(); HorarioReceso::query()->whereIn('diadeatencion_id', $diasExistentes)->delete(); DiaDeAtencion::query()->whereIn('id', $diasExistentes)->delete(); } ModoVacaciones::query()->where('agenda_id', (int) $agenda->id)->delete(); Feriado::query()->where('agenda_id', (int) $agenda->id)->delete(); $diaAtencionPorDia = []; foreach ($diasInput as $fila) { $diaId = (int) $fila['dia_id']; if (!isset($diaAtencionPorDia[$diaId])) { $diaAtencionPorDia[$diaId] = DiaDeAtencion::create([ 'agenda_id' => (int) $agenda->id, 'dia_id' => $diaId, ]); } HorarioDeAtencion::create([ 'horariocomienzo' => $fila['horariocomienzo'], 'horariofin' => $fila['horariofin'], 'tipo' => $fila['tipo'], 'diadeatencion_id' => (int) $diaAtencionPorDia[$diaId]->id, ]); } foreach ($recesosInput as $fila) { $diaId = (int) $fila['dia_id']; if (!isset($diaAtencionPorDia[$diaId])) { $diaAtencionPorDia[$diaId] = DiaDeAtencion::create([ 'agenda_id' => (int) $agenda->id, 'dia_id' => $diaId, ]); } HorarioReceso::create([ 'comienzo' => $fila['comienzo'], 'fin' => $fila['fin'], 'diadeatencion_id' => (int) $diaAtencionPorDia[$diaId]->id, ]); } foreach ($vacacionesInput as $fila) { ModoVacaciones::create([ 'inicio' => $fila['inicio'], 'fin' => $fila['fin'], 'descripcion' => $fila['descripcion'] !== '' ? $fila['descripcion'] : 'Vacaciones :)', 'agenda_id' => (int) $agenda->id, ]); } foreach ($feriadosInput as $fila) { Feriado::create([ 'fecha' => $fila['fecha'], 'descripcion' => $fila['descripcion'] !== '' ? $fila['descripcion'] : 'Dia no laborable', 'agenda_id' => (int) $agenda->id, ]); } }); return redirect('/profesional/configurar-agenda')->with('agenda_success', 'Agenda actualizada correctamente.'); }); Route::get('/profesional/clientes/crear', function (Request $request) { $dniConsultado = trim((string) $request->query('dni', old('dni', ''))); $personaDni = null; $estadoDni = null; if ($dniConsultado !== '') { $personaDni = Persona::query() ->where('dni', $dniConsultado) ->first(); if ($personaDni) { $clienteExistente = Cliente::query() ->where('persona_id', $personaDni->id) ->orWhere('dni', $dniConsultado) ->exists(); $estadoDni = $clienteExistente ? 'cliente-existente' : 'persona-existente'; } else { $estadoDni = 'persona-nueva'; } } return view('profesional.agregar-cliente', [ 'dniConsultado' => $dniConsultado, 'personaDni' => $personaDni, 'estadoDni' => $estadoDni, ]); }); Route::post('/profesional/clientes/store', function (Request $request) use ($registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); $profesionalSesionId = (int) ($profesional?->id ?? 0); if (!$profesionalSesionId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $validated = $request->validate([ 'dni' => ['required', 'string', 'max:20', 'regex:/^[0-9A-Za-z]{7,20}$/'], 'nombre' => ['nullable', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['nullable', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'cuil' => ['nullable', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'correo' => ['required', 'email', 'max:255', 'unique:credencialesclientes,correo'], 'fechanac' => ['nullable', 'date'], 'telefono' => ['nullable', 'string', 'max:30', 'regex:/^[0-9]+$/'], ]); $personaExistente = Persona::query() ->where('dni', $validated['dni']) ->first(); if ($personaExistente) { $clienteExistente = Cliente::query() ->where('persona_id', $personaExistente->id) ->orWhere('dni', $validated['dni']) ->exists(); if ($clienteExistente) { return back() ->withErrors(['dni' => 'El DNI ingresado ya pertenece a un cliente registrado.']) ->withInput(); } } else { $mensajesError = []; if (blank($validated['nombre'] ?? null)) { $mensajesError['nombre'] = 'El nombre es obligatorio cuando el DNI no pertenece a una persona registrada.'; } if (blank($validated['apellido'] ?? null)) { $mensajesError['apellido'] = 'El apellido es obligatorio cuando el DNI no pertenece a una persona registrada.'; } if (blank($validated['cuil'] ?? null)) { $mensajesError['cuil'] = 'El CUIL es obligatorio cuando el DNI no pertenece a una persona registrada.'; } if (blank($validated['fechanac'] ?? null)) { $mensajesError['fechanac'] = 'La fecha de nacimiento es obligatoria cuando el DNI no pertenece a una persona registrada.'; } if (blank($validated['telefono'] ?? null)) { $mensajesError['telefono'] = 'El telefono es obligatorio cuando el DNI no pertenece a una persona registrada.'; } if (!empty($mensajesError)) { return back() ->withErrors($mensajesError) ->withInput(); } } $clienteCreado = null; DB::transaction(function () use ($validated, $personaExistente, $profesionalSesionId, &$clienteCreado): void { $credencialCliente = \App\Models\CredencialCliente::create([ 'correo' => $validated['correo'], 'contra' => Hash::make($validated['dni']), 'token' => null, 'fecha_hora' => now(), ]); $persona = $personaExistente; if (!$persona) { $fotoDefaultId = Foto::query() ->where('ruta', 'images/avatar_default.png') ->value('id'); if (!$fotoDefaultId) { $fotoDefaultId = Foto::query()->create([ 'extension' => 'png', 'tamanio_bytes' => 0, 'nombre' => 'avatar_default', 'mime_type' => 'image/png', 'ruta' => 'images/avatar_default.png', ])->id; } $persona = Persona::create([ 'dni' => $validated['dni'], 'nombre' => $validated['nombre'], 'apellido' => $validated['apellido'], 'cuil' => $validated['cuil'] ?? null, 'fechanac' => $validated['fechanac'] ?? null, 'foto_id' => (int) $fotoDefaultId, ]); $telefono = Telefono::create([ 'telefono' => $validated['telefono'], ]); DB::table('personas_telefonos')->insert([ 'dni' => $validated['dni'], 'persona_id' => $persona->id, 'telefono_id' => $telefono->id, 'created_at' => now(), 'updated_at' => now(), ]); } $cliente = Cliente::create([ 'dni' => $validated['dni'], 'correo' => $validated['correo'], 'credencialcliente_id' => $credencialCliente->id, 'persona_id' => $persona->id, 'baja_id' => 1, ]); $clienteCreado = $cliente; DB::table('profesionales_clientes')->insertOrIgnore([ 'profesional_id' => $profesionalSesionId, 'cliente_id' => $cliente->id, 'estadorelacion' => 'Activo', 'created_at' => now(), 'updated_at' => now(), ]); }); if ($clienteCreado) { $registrarLogSeguridadProfesional( $request, 'Creación nuevo cliente', 'El profesional ID ' . $profesionalSesionId . ' creó el cliente ID ' . $clienteCreado->id . ' con DNI ' . $clienteCreado->dni . '.' ); } return redirect('/profesional/clientes')->with('success', 'Cliente agregado correctamente.'); }); Route::get('/profesional/clientes/{cliente}/editar', function (Request $request, Cliente $cliente) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesionalSesionId = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->value('id'); if (!$profesionalSesionId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesionalSesionId) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes editar un cliente no asignado a tu cuenta.']); } $cliente->load(['persona', 'credencialCliente']); $telefonoActual = $cliente->persona?->telefonos()->first(); return view('profesional.editar-cliente', [ 'cliente' => $cliente, 'telefonoActual' => $telefonoActual, ]); }); Route::post('/profesional/clientes/{cliente}/actualizar', function (Request $request, Cliente $cliente) use ($registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes editar un cliente no asignado a tu cuenta.']); } $cliente->load(['persona', 'credencialCliente']); $dniAnterior = (string) $cliente->dni; $dniRules = [ 'required', 'string', 'regex:/^[0-9A-Za-z]{7,20}$/', Rule::unique('clientes', 'dni')->ignore((int) $cliente->id), ]; if ($cliente->persona) { $dniRules[] = Rule::unique('personas', 'dni')->ignore((int) $cliente->persona->id); } $validated = $request->validate([ 'dni' => $dniRules, 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'cuil' => ['required', 'string', 'regex:/^[0-9]{11}$/'], 'fechanac' => ['required', 'date', 'before_or_equal:today'], 'correo' => [ 'required', 'email', 'max:255', Rule::unique('clientes', 'correo')->ignore((int) $cliente->id), Rule::unique('credencialesclientes', 'correo')->ignore((int) ($cliente->credencialcliente_id ?? 0)), ], 'telefono' => ['required', 'string', 'regex:/^[0-9]{8,15}$/'], ]); DB::transaction(function () use ($cliente, $validated, $profesional): void { if ($cliente->persona) { $cliente->persona->update([ 'dni' => $validated['dni'], 'nombre' => $validated['nombre'], 'apellido' => $validated['apellido'], 'cuil' => $validated['cuil'], 'fechanac' => $validated['fechanac'], ]); DB::table('personas_telefonos') ->where('persona_id', (int) $cliente->persona->id) ->update([ 'dni' => $validated['dni'], 'updated_at' => now(), ]); $telefonoExistente = $cliente->persona->telefonos()->first(); if ($telefonoExistente) { $telefonoExistente->update([ 'telefono' => $validated['telefono'], ]); } else { $telefonoNuevo = Telefono::create([ 'telefono' => $validated['telefono'], ]); DB::table('personas_telefonos')->insertOrIgnore([ 'dni' => $validated['dni'], 'persona_id' => (int) $cliente->persona->id, 'telefono_id' => (int) $telefonoNuevo->id, 'created_at' => now(), 'updated_at' => now(), ]); } } $cliente->update([ 'dni' => $validated['dni'], 'correo' => $validated['correo'], ]); DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->update([ 'estadorelacion' => 'Activo', 'updated_at' => now(), ]); if ($cliente->credencialCliente) { $cliente->credencialCliente->update([ 'correo' => $validated['correo'], ]); } }); $registrarLogSeguridadProfesional( $request, 'Edición datos cliente', 'El profesional ID ' . (int) $profesional->id . ' editó el cliente ID ' . (int) $cliente->id . '.' ); if ($dniAnterior !== (string) $validated['dni']) { $registrarLogSeguridadProfesional( $request, 'Cambio de DNI Cliente', 'Cambio de DNI del cliente ID ' . (int) $cliente->id . ': "' . $dniAnterior . '" -> "' . $validated['dni'] . '".' ); } return redirect('/profesional/clientes')->with('success', 'Cliente actualizado correctamente.'); }); Route::post('/profesional/clientes/{cliente}/baja', function (Request $request, Cliente $cliente) use ($registrarLogSeguridadProfesional) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes modificar el estado de un cliente no asignado a tu cuenta.']); } $estabaDeBaja = (int) $cliente->baja_id !== 1; $nombreCliente = trim(($cliente->persona?->nombre ?? '') . ' ' . ($cliente->persona?->apellido ?? '')); DB::transaction(function () use ($cliente, $profesional): void { if ((int) $cliente->baja_id !== 1) { $cliente->baja_id = 1; $cliente->save(); DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->update([ 'estadorelacion' => 'Activo', 'updated_at' => now(), ]); return; } $bajaId = (int) Baja::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['baja']) ->value('id'); if ($bajaId <= 0) { $bajaId = 2; } $cliente->baja_id = $bajaId; $cliente->save(); DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->update([ 'estadorelacion' => 'Baja', 'updated_at' => now(), ]); }); if ($estabaDeBaja) { $registrarLogSeguridadProfesional( $request, 'Dar de alta cliente', 'El profesional ID ' . (int) $profesional->id . ' dio de alta al cliente ID ' . (int) $cliente->id . ' (' . $nombreCliente . ').' ); $registrarLogSeguridadProfesional( $request, 'dio de alta relacion con cliente', 'El profesional ID ' . (int) $profesional->id . ' dio de alta la relación con el cliente ID ' . (int) $cliente->id . ' (' . $nombreCliente . ').' ); return redirect('/profesional/clientes')->with('success', 'Cliente reactivado correctamente.'); } $registrarLogSeguridadProfesional( $request, 'Dar de baja cliente', 'El profesional ID ' . (int) $profesional->id . ' dio de baja al cliente ID ' . (int) $cliente->id . ' (' . $nombreCliente . ').' ); $registrarLogSeguridadProfesional( $request, 'dio de baja relacion con cliente', 'El profesional ID ' . (int) $profesional->id . ' dio de baja la relación con el cliente ID ' . (int) $cliente->id . ' (' . $nombreCliente . ').' ); return redirect('/profesional/clientes')->with('success', 'Cliente dado de baja correctamente.'); }); Route::get('/profesional/clientes/{cliente}/documentacion', function (Request $request, Cliente $cliente) use ($resolverRutaDocumentacionCliente) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes acceder a la documentación de un cliente no asignado a tu cuenta.']); } $cliente->load('persona'); $documentos = DocumentacionCliente::query() ->where('cliente_id', (int) $cliente->id) ->latest('id') ->get() ->map(function (DocumentacionCliente $documento) use ($cliente, $resolverRutaDocumentacionCliente) { $rutaArchivo = $resolverRutaDocumentacionCliente((int) $cliente->id, (string) $documento->nombre); $documento->ruta_relativa = $rutaArchivo ? ('documentacion-clientes/cliente_' . (int) $cliente->id . '/' . $documento->nombre) : null; $documento->url_archivo = $rutaArchivo ? url('/profesional/clientes/' . $cliente->id . '/documentacion/' . $documento->id . '/ver') : null; $documento->archivo_existe = $rutaArchivo !== null && File::exists($rutaArchivo); $nombreVisible = preg_replace('/^\d{14}_[a-z0-9]{6}_/i', '', (string) $documento->nombre); $documento->nombre_visible = str_replace(['-', '_'], ' ', (string) $nombreVisible); return $documento; }); return view('profesional.documentacion-cliente', [ 'cliente' => $cliente, 'documentos' => $documentos, ]); }); Route::post('/profesional/clientes/{cliente}/documentacion', function (Request $request, Cliente $cliente) use ($registrarLogSeguridadProfesional, $directorioDocumentacionPrivada) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes cargar documentación a un cliente no asignado a tu cuenta.']); } $validated = $request->validate([ 'documentos' => ['required', 'array', 'min:1'], 'documentos.*' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png,webp,doc,docx,xlsx,txt', 'max:5120'], ]); $directorio = $directorioDocumentacionPrivada((int) $cliente->id); if (!File::isDirectory($directorio)) { File::makeDirectory($directorio, 0755, true); } foreach ($validated['documentos'] as $archivo) { $nombreOriginal = (string) $archivo->getClientOriginalName(); $mimeType = (string) $archivo->getClientMimeType(); $rutaTemporal = $archivo->getRealPath(); $tamanioBytes = ($rutaTemporal && is_file($rutaTemporal)) ? (int) filesize($rutaTemporal) : 0; $extension = strtolower((string) ($archivo->getClientOriginalExtension() ?: $archivo->extension() ?: 'bin')); $nombreBase = Str::slug(pathinfo($nombreOriginal, PATHINFO_FILENAME)); if ($nombreBase === '') { $nombreBase = 'documento'; } $nombreGuardado = now()->format('YmdHis') . '_' . Str::lower(Str::random(6)) . '_' . $nombreBase . '.' . $extension; $archivo->move($directorio, $nombreGuardado); DocumentacionCliente::create([ 'nombre' => $nombreGuardado, 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'extension' => $extension, 'cliente_id' => (int) $cliente->id, 'profesional_id' => (int) $profesional->id, ]); } $registrarLogSeguridadProfesional( $request, 'Agregó documentación cliente', 'El profesional ID ' . (int) $profesional->id . ' agregó ' . count($validated['documentos']) . ' documento(s) al cliente ID ' . (int) $cliente->id . '.' ); return redirect('/profesional/clientes/' . $cliente->id . '/documentacion') ->with('success', 'Documentación agregada correctamente.'); }); Route::get('/profesional/clientes/{cliente}/documentacion/{documentacion}/ver', function (Request $request, Cliente $cliente, DocumentacionCliente $documentacion) use ($resolverRutaDocumentacionCliente) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional || (int) $documentacion->cliente_id !== (int) $cliente->id) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes visualizar documentación de un cliente no asignado a tu cuenta.']); } $rutaArchivo = $resolverRutaDocumentacionCliente((int) $cliente->id, (string) $documentacion->nombre); if (!$rutaArchivo || !File::exists($rutaArchivo)) { return redirect('/profesional/clientes/' . $cliente->id . '/documentacion') ->withErrors(['documento' => 'El archivo no se encuentra disponible para visualizar.']); } $nombreDescarga = $documentacion->nombre_visible ?? preg_replace('/^\d{14}_[a-z0-9]{6}_/i', '', (string) $documentacion->nombre); $mimeType = trim((string) ($documentacion->mime_type ?? '')); if ($mimeType === '') { $mimeType = File::mimeType($rutaArchivo) ?: 'application/octet-stream'; } return response()->file($rutaArchivo, [ 'Content-Type' => $mimeType, 'Content-Disposition' => 'inline; filename="' . addslashes((string) $nombreDescarga) . '"', 'X-Content-Type-Options' => 'nosniff', ]); }); Route::get('/profesional/clientes/{cliente}/documentacion/{documentacion}/descargar', function (Request $request, Cliente $cliente, DocumentacionCliente $documentacion) use ($resolverRutaDocumentacionCliente) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional || (int) $documentacion->cliente_id !== (int) $cliente->id) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes descargar documentación de un cliente no asignado a tu cuenta.']); } $rutaArchivo = $resolverRutaDocumentacionCliente((int) $cliente->id, (string) $documentacion->nombre); if (!$rutaArchivo || !File::exists($rutaArchivo)) { return redirect('/profesional/clientes/' . $cliente->id . '/documentacion') ->withErrors(['documento' => 'El archivo no se encuentra disponible para descargar.']); } $nombreDescarga = $documentacion->nombre_visible ?? preg_replace('/^\d{14}_[a-z0-9]{6}_/i', '', (string) $documentacion->nombre); return response()->download($rutaArchivo, (string) $nombreDescarga, [ 'X-Content-Type-Options' => 'nosniff', ]); }); Route::post('/profesional/clientes/{cliente}/documentacion/{documentacion}/eliminar', function (Request $request, Cliente $cliente, DocumentacionCliente $documentacion) use ($registrarLogSeguridadProfesional, $eliminarDocumentacionCliente) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $clientePerteneceAlProfesional = DB::table('profesionales_clientes') ->where('profesional_id', (int) $profesional->id) ->where('cliente_id', (int) $cliente->id) ->exists(); if (!$clientePerteneceAlProfesional || (int) $documentacion->cliente_id !== (int) $cliente->id) { return redirect('/profesional/clientes')->withErrors(['cliente' => 'No puedes eliminar documentación de un cliente no asignado a tu cuenta.']); } $eliminarDocumentacionCliente((int) $cliente->id, (string) $documentacion->nombre); $nombreDocumento = (string) $documentacion->nombre; $documentacion->delete(); $registrarLogSeguridadProfesional( $request, 'Eliminó documentación cliente', 'El profesional ID ' . (int) $profesional->id . ' eliminó el documento "' . $nombreDocumento . '" del cliente ID ' . (int) $cliente->id . '.' ); return redirect('/profesional/clientes/' . $cliente->id . '/documentacion') ->with('success', 'Documento eliminado correctamente.'); }); Route::get('/profesional/formularios', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesionalSesionId = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->value('id'); if (!$profesionalSesionId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $filtroTexto = trim((string) $request->query('q', '')); $filtroEstado = trim((string) $request->query('estado', '')); $filtroCliente = trim((string) $request->query('cliente', '')); $formulariosQuery = Formulario::query() ->with(['profesion', 'servicio', 'modalidad']) ->whereExists(function ($query) use ($profesionalSesionId): void { $query->select(DB::raw(1)) ->from('profesionales_formularios as pf') ->whereColumn('pf.formulario_id', 'formularios.id') ->where('pf.profesional_id', (int) $profesionalSesionId); }); if ($filtroTexto !== '') { $formulariosQuery->where(function ($query) use ($filtroTexto): void { $query->where('formularios.nombrecompleto', 'like', '%' . $filtroTexto . '%') ->orWhere('formularios.correo', 'like', '%' . $filtroTexto . '%') ->orWhere('formularios.celular', 'like', '%' . $filtroTexto . '%') ->orWhere('formularios.descripcion', 'like', '%' . $filtroTexto . '%') ->orWhereHas('servicio', function ($qServicio) use ($filtroTexto): void { $qServicio->where('titulo', 'like', '%' . $filtroTexto . '%'); }) ->orWhereHas('profesion', function ($qProfesion) use ($filtroTexto): void { $qProfesion->where('titulo', 'like', '%' . $filtroTexto . '%'); }); }); } if ($filtroCliente === 'si') { $formulariosQuery->whereNotNull('formularios.cliente_id'); } elseif ($filtroCliente === 'no') { $formulariosQuery->whereNull('formularios.cliente_id'); } if ($filtroEstado !== '') { if ($filtroEstado === 'aceptado_por_otro') { $formulariosQuery ->whereRaw("LOWER(TRIM(formularios.estado)) = 'aceptado'") ->whereExists(function ($query) use ($profesionalSesionId): void { $query->select(DB::raw(1)) ->from('profesionales_formularios as pf') ->whereColumn('pf.formulario_id', 'formularios.id') ->where('pf.profesional_id', '!=', (int) $profesionalSesionId) ->whereRaw("LOWER(TRIM(pf.estado)) = 'aceptado'"); }); } elseif ($filtroEstado === 'aceptado') { $formulariosQuery ->whereRaw("LOWER(TRIM(formularios.estado)) = 'aceptado'") ->whereNotExists(function ($query) use ($profesionalSesionId): void { $query->select(DB::raw(1)) ->from('profesionales_formularios as pf') ->whereColumn('pf.formulario_id', 'formularios.id') ->where('pf.profesional_id', '!=', (int) $profesionalSesionId) ->whereRaw("LOWER(TRIM(pf.estado)) = 'aceptado'"); }); } elseif ($filtroEstado === 'rechazado') { $formulariosQuery->whereRaw("LOWER(TRIM(formularios.estado)) = 'rechazado por todos'"); } else { $formulariosQuery->whereRaw('LOWER(TRIM(formularios.estado)) = ?', [mb_strtolower($filtroEstado)]); } } $formularios = $formulariosQuery ->orderByRaw('CASE WHEN formularios.cliente_id IS NOT NULL THEN 0 ELSE 1 END') ->latest('id') ->paginate(10) ->withQueryString(); $formulariosPaginaIds = $formularios->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(); $aceptadosPorOtroIds = []; if (!empty($formulariosPaginaIds)) { $aceptadosPorOtroIds = DB::table('profesionales_formularios as pf') ->join('formularios as f', 'f.id', '=', 'pf.formulario_id') ->whereIn('pf.formulario_id', $formulariosPaginaIds) ->where('pf.profesional_id', '!=', (int) $profesionalSesionId) ->whereRaw("LOWER(TRIM(pf.estado)) = 'aceptado'") ->whereRaw("LOWER(TRIM(f.estado)) = 'aceptado'") ->pluck('pf.formulario_id') ->map(fn ($id) => (int) $id) ->all(); } $formularios->getCollection()->transform(function ($formulario) use ($aceptadosPorOtroIds) { $formulario->aceptado_por_otro = in_array((int) $formulario->id, $aceptadosPorOtroIds, true); return $formulario; }); return view('profesional.revisar-formularios', [ 'formularios' => $formularios, ]); }); Route::get('/profesional/formularios/{formulario}', function (Formulario $formulario) use ($buscarTurnoAutomatico, $formularioFueAceptadoPorOtroProfesional) { $formulario->load(['profesion', 'servicio', 'modalidad', 'diasPreferidos', 'horariosPreferidos']); $credencialId = (int) request()->session()->get('personal_credencial_id', 0); $profesionalSesionId = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->value('id'); $profesionalPreferido = null; if ($formulario->profesional_id) { $profesionalPreferido = Profesional::with('persona')->find($formulario->profesional_id); } $estadoProfesionalFormulario = 'Pendiente'; $formularioAceptadoPorOtro = false; $agendaEventosProfesional = collect(); $turnoAutomaticoSugerido = null; if ($profesionalSesionId) { $esAsignado = DB::table('profesionales_formularios') ->where('formulario_id', (int) $formulario->id) ->where('profesional_id', (int) $profesionalSesionId) ->exists(); if (!$esAsignado) { return redirect('/profesional/formularios') ->with('profesional_action_error', 'No tenes permiso para ver este formulario.'); } $estadoPivot = DB::table('profesionales_formularios') ->where('formulario_id', (int) $formulario->id) ->where('profesional_id', (int) $profesionalSesionId) ->value('estado'); if (is_string($estadoPivot) && trim($estadoPivot) !== '') { $estadoProfesionalFormulario = trim($estadoPivot); } $formularioAceptadoPorOtro = $formularioFueAceptadoPorOtroProfesional((int) $formulario->id, (int) $profesionalSesionId); $coloresEstado = [ 'confirmado' => '#198754', 'cancelado' => '#dc3545', 'reprogramado' => '#fd7e14', 'cliente ausente' => '#6c757d', 'cliente presente' => '#0d6efd', ]; $duracionTurnoVisualForm = (int) (Agenda::query() ->where('profesional_id', (int) $profesionalSesionId) ->value('duracionturno') ?? 30); $agendaEventosProfesional = Turno::query() ->with(['servicio', 'modalidad', 'estadoTurno', 'cliente.persona.telefonos']) ->where('profesional_id', (int) $profesionalSesionId) ->orderBy('inicio') ->get() ->filter(fn ($turno) => $turno->inicio) ->filter(function ($turno) { $estadoDescripcion = mb_strtolower(trim((string) ($turno->estadoTurno?->descripcion ?? ''))); return $estadoDescripcion !== 'cancelado'; }) ->map(function ($turno) use ($coloresEstado, $duracionTurnoVisualForm) { $estadoDescripcion = trim((string) ($turno->estadoTurno?->descripcion ?? 'Sin estado')); $estadoKey = mb_strtolower($estadoDescripcion); $color = $coloresEstado[$estadoKey] ?? '#0d6efd'; // Naranja para turnos asignados manualmente (sin cliente vinculado y estado confirmado) if ($estadoKey === 'confirmado' && $turno->cliente_id === null) { $color = '#fd7e14'; } $servicioTitulo = trim((string) ($turno->servicio?->titulo ?? 'Turno')); $nombreCompleto = trim((string) ($turno->nombrecompleto ?? 'Cliente')); $celularCliente = trim((string) ($turno->cliente?->persona?->telefonos?->first()?->telefono ?? '')); if ($celularCliente === '') { $celularCliente = trim((string) ($turno->celular ?? '')); } return [ 'id' => (int) $turno->id, 'title' => $servicioTitulo . ' - ' . $nombreCompleto, 'start' => $turno->inicio->format('Y-m-d\TH:i:s'), 'end' => $turno->inicio->copy()->addMinutes($duracionTurnoVisualForm)->format('Y-m-d\TH:i:s'), 'backgroundColor' => $color, 'borderColor' => $color, 'extendedProps' => [ 'prioridad' => 4, 'cliente' => $nombreCompleto, 'correo' => (string) ($turno->correo ?? ''), 'celular' => $celularCliente !== '' ? $celularCliente : '-', 'servicio' => $servicioTitulo, 'estado' => $estadoDescripcion, 'modalidad' => (string) ($turno->modalidad?->descripcion ?? '-'), ], ]; }) ->values(); // Eventos de disponibilidad (verde) y no disponibilidad (rojo) $numeroDiaSemanaForm = [ 'domingo' => 0, 'lunes' => 1, 'martes' => 2, 'miercoles' => 3, 'miércoles' => 3, 'jueves' => 4, 'viernes' => 5, 'sabado' => 6, 'sábado' => 6, ]; $agendaForm = Agenda::query() ->with(['diaDeAtencion.dias', 'diaDeAtencion.horariosAtenciones', 'diaDeAtencion.horariosRecesos', 'feriado', 'modoVacaciones']) ->where('profesional_id', (int) $profesionalSesionId) ->first(); $inicioRangoForm = now()->startOfDay(); $finRangoForm = now()->addDays(90)->endOfDay(); $eventosDispForm = collect($agendaForm?->diaDeAtencion ?? []) ->flatMap(function ($diaAtencion) use ($numeroDiaSemanaForm, $inicioRangoForm, $finRangoForm) { $diaDesc = mb_strtolower(trim((string) ($diaAtencion->dias?->descripcion ?? ''))); $diaSem = $numeroDiaSemanaForm[$diaDesc] ?? null; if ($diaSem === null) { return []; } return collect($diaAtencion->horariosAtenciones ?? []) ->filter(fn ($h) => $h->horariocomienzo && $h->horariofin) ->flatMap(function ($horario) use ($diaSem, $diaAtencion, $inicioRangoForm, $finRangoForm) { $eventos = []; $cursor = $inicioRangoForm->copy(); while ($cursor->lte($finRangoForm)) { if ((int) $cursor->dayOfWeek === (int) $diaSem) { $fecha = $cursor->format('Y-m-d'); $eventos[] = [ 'id' => 'disp-f-' . $diaAtencion->id . '-' . $horario->id . '-' . $fecha, 'title' => 'Disponibilidad', 'start' => $fecha . 'T' . substr((string) $horario->horariocomienzo, 0, 8), 'end' => $fecha . 'T' . substr((string) $horario->horariofin, 0, 8), 'display' => 'background', 'classNames' => ['evento-disponibilidad'], 'backgroundColor' => '#198754', 'borderColor' => '#198754', 'extendedProps' => ['prioridad' => 5, 'tipoEvento' => 'disponibilidad', 'estado' => 'Disponible', 'modalidad' => '-'], ]; } $cursor->addDay(); } return $eventos; }); }) ->values(); $eventosRecesoForm = collect($agendaForm?->diaDeAtencion ?? []) ->flatMap(function ($diaAtencion) use ($numeroDiaSemanaForm, $inicioRangoForm, $finRangoForm) { $diaDesc = mb_strtolower(trim((string) ($diaAtencion->dias?->descripcion ?? ''))); $diaSem = $numeroDiaSemanaForm[$diaDesc] ?? null; if ($diaSem === null) { return []; } return collect($diaAtencion->horariosRecesos ?? []) ->filter(fn ($r) => $r->comienzo && $r->fin) ->flatMap(function ($receso) use ($diaSem, $diaAtencion, $inicioRangoForm, $finRangoForm) { $eventos = []; $cursor = $inicioRangoForm->copy(); while ($cursor->lte($finRangoForm)) { if ((int) $cursor->dayOfWeek === (int) $diaSem) { $fecha = $cursor->format('Y-m-d'); $descripcionReceso = trim((string) ($receso->descripcion ?? '')); $tituloReceso = $descripcionReceso !== '' ? 'Receso: ' . $descripcionReceso : 'Receso'; $eventos[] = [ 'id' => 'receso-f-' . $diaAtencion->id . '-' . $receso->id . '-' . $fecha, 'title' => $tituloReceso, 'start' => $fecha . 'T' . substr((string) $receso->comienzo, 0, 8), 'end' => $fecha . 'T' . substr((string) $receso->fin, 0, 8), 'display' => 'background', 'classNames' => ['evento-receso'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => ['prioridad' => 3, 'tipoEvento' => 'no_disponible', 'motivo' => 'Receso', 'estado' => 'No disponible'], ]; } $cursor->addDay(); } return $eventos; }); }) ->values(); $eventosVacacionesForm = collect($agendaForm?->modoVacaciones ?? []) ->filter(fn ($v) => $v->inicio && $v->fin) ->map(function ($vacacion) { $descripcionVacacion = trim((string) ($vacacion->descripcion ?? '')); return [ 'id' => 'vacaciones-f-' . $vacacion->id, 'title' => $descripcionVacacion !== '' ? 'Vacaciones: ' . $descripcionVacacion : 'Vacaciones', 'start' => (string) $vacacion->inicio, 'end' => \Illuminate\Support\Carbon::parse((string) $vacacion->fin)->addDay()->format('Y-m-d'), 'allDay' => true, 'display' => 'background', 'classNames' => ['evento-vacaciones'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => ['prioridad' => 2, 'tipoEvento' => 'no_disponible', 'motivo' => 'Vacaciones', 'estado' => 'No disponible'], ]; }) ->values(); $eventosFeriadosForm = collect($agendaForm?->feriado ?? []) ->filter(fn ($f) => $f->fecha) ->map(function ($feriado) { $descripcionFeriado = trim((string) ($feriado->descripcion ?? '')); return [ 'id' => 'feriado-f-' . $feriado->id, 'title' => $descripcionFeriado !== '' ? 'Feriado: ' . $descripcionFeriado : 'Feriado', 'start' => (string) $feriado->fecha, 'end' => \Illuminate\Support\Carbon::parse((string) $feriado->fecha)->addDay()->format('Y-m-d'), 'allDay' => true, 'display' => 'background', 'classNames' => ['evento-feriado'], 'textColor' => '#5b1f23', 'backgroundColor' => '#dc3545', 'borderColor' => '#dc3545', 'extendedProps' => ['prioridad' => 1, 'tipoEvento' => 'no_disponible', 'motivo' => 'Feriado', 'estado' => 'No disponible'], ]; }) ->values(); $duracionTurnoForm = (int) ($agendaForm?->duracionturno ?? 30); $rangosTurnosOcupadosForm = Turno::query() ->with('estadoTurno') ->where('profesional_id', (int) $profesionalSesionId) ->get() ->filter(fn ($turno) => $turno->inicio) ->filter(function ($turno) { $estadoDescripcion = mb_strtolower(trim((string) ($turno->estadoTurno?->descripcion ?? ''))); return $estadoDescripcion !== 'cancelado'; }) ->map(function ($turno) use ($duracionTurnoForm) { $inicio = \Illuminate\Support\Carbon::parse((string) $turno->inicio); return [ 'inicio' => $inicio->copy(), 'fin' => $inicio->copy()->addMinutes($duracionTurnoForm), ]; }) ->values(); $rangosNoDispo = collect() ->concat($rangosTurnosOcupadosForm) ->concat($eventosRecesoForm) ->concat($eventosVacacionesForm) ->concat($eventosFeriadosForm) ->map(function ($ev) { $ini = isset($ev['start']) ? \Illuminate\Support\Carbon::parse((string) $ev['start']) : null; $fin = isset($ev['end']) ? \Illuminate\Support\Carbon::parse((string) $ev['end']) : null; if (isset($ev['inicio'], $ev['fin'])) { return ['inicio' => $ev['inicio']->copy(), 'fin' => $ev['fin']->copy()]; } return ($ini && $fin) ? ['inicio' => $ini, 'fin' => $fin] : null; }) ->filter() ->values(); $eventosDispForm = $eventosDispForm ->flatMap(function ($evento) use ($rangosNoDispo) { if (!isset($evento['start'], $evento['end'])) { return [$evento]; } $iniEv = \Illuminate\Support\Carbon::parse((string) $evento['start']); $finEv = \Illuminate\Support\Carbon::parse((string) $evento['end']); $solapados = $rangosNoDispo ->filter(fn ($r) => $iniEv->lt($r['fin']) && $finEv->gt($r['inicio'])) ->map(fn ($r) => [ 'inicio' => $r['inicio']->gt($iniEv) ? $r['inicio']->copy() : $iniEv->copy(), 'fin' => $r['fin']->lt($finEv) ? $r['fin']->copy() : $finEv->copy(), ]) ->values() ->all(); if (empty($solapados)) { return [$evento]; } usort($solapados, fn ($a, $b) => $a['inicio']->lessThan($b['inicio']) ? -1 : ($a['inicio']->equalTo($b['inicio']) ? 0 : 1)); $segmentos = [['inicio' => $iniEv->copy(), 'fin' => $finEv->copy()]]; foreach ($solapados as $rango) { $nuevos = []; foreach ($segmentos as $seg) { if ($seg['fin']->lessThanOrEqualTo($rango['inicio']) || $seg['inicio']->greaterThanOrEqualTo($rango['fin'])) { $nuevos[] = $seg; continue; } if ($seg['inicio']->lt($rango['inicio'])) { $nuevos[] = ['inicio' => $seg['inicio']->copy(), 'fin' => $rango['inicio']->copy()]; } if ($seg['fin']->gt($rango['fin'])) { $nuevos[] = ['inicio' => $rango['fin']->copy(), 'fin' => $seg['fin']->copy()]; } } $segmentos = $nuevos; } return collect($segmentos) ->filter(fn ($s) => $s['inicio']->lt($s['fin'])) ->values() ->map(function ($s, $idx) use ($evento) { $ev = $evento; $ev['id'] = (string) ($evento['id'] ?? 'disp') . '-seg-' . $idx; $ev['start'] = $s['inicio']->format('Y-m-d\TH:i:s'); $ev['end'] = $s['fin']->format('Y-m-d\TH:i:s'); return $ev; }) ->all(); }) ->values(); $agendaEventosProfesional = collect() ->concat($eventosFeriadosForm) ->concat($eventosVacacionesForm) ->concat($eventosRecesoForm) ->concat($agendaEventosProfesional) ->concat($eventosDispForm) ->values(); if (!$formularioAceptadoPorOtro) { $turnoAutomaticoSugerido = $buscarTurnoAutomatico($formulario, (int) $profesionalSesionId); } } return view('profesional.detalle-formulario', [ 'formulario' => $formulario, 'profesionalPreferido' => $profesionalPreferido, 'estadoProfesionalFormulario' => $estadoProfesionalFormulario, 'formularioAceptadoPorOtro' => $formularioAceptadoPorOtro, 'agendaEventosProfesional' => $agendaEventosProfesional, 'turnoAutomaticoSugerido' => $turnoAutomaticoSugerido, ]); }); Route::post('/profesional/formularios/{formulario}/rechazar', function (Request $request, Formulario $formulario) use ($formularioFueAceptadoPorOtroProfesional, $registrarLogSeguridadProfesional, $recalcularEstadoGeneralFormulario) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); $profesionalId = $profesional?->id; if (!$profesionalId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $estadoRelacionAnterior = mb_strtolower(trim((string) DB::table('profesionales_formularios') ->where('profesional_id', (int) $profesionalId) ->where('formulario_id', (int) $formulario->id) ->value('estado'))); $esAsignado = DB::table('profesionales_formularios') ->where('profesional_id', (int) $profesionalId) ->where('formulario_id', (int) $formulario->id) ->exists(); if (!$esAsignado) { return redirect('/profesional/formularios') ->with('profesional_action_error', 'No tenes permiso para rechazar este formulario.'); } if ($formularioFueAceptadoPorOtroProfesional((int) $formulario->id, (int) $profesionalId)) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'Este formulario ya fue aceptado por otro profesional.'); } $estadoNuevoProfesional = $estadoRelacionAnterior === 'aceptado' ? 'Pendiente' : 'Rechazado'; DB::table('profesionales_formularios') ->where('profesional_id', (int) $profesionalId) ->where('formulario_id', (int) $formulario->id) ->update([ 'estado' => $estadoNuevoProfesional, ]); $recalcularEstadoGeneralFormulario($formulario); $registrarLogSeguridadProfesional( $request, $estadoRelacionAnterior === 'aceptado' ? 'Devolvió un caso' : 'Rechazó un caso', $estadoRelacionAnterior === 'aceptado' ? 'El profesional ID ' . $profesionalId . ' devolvió el formulario ID ' . $formulario->id . '.' : 'El profesional ID ' . $profesionalId . ' rechazó el formulario ID ' . $formulario->id . '.' ); return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_success', $estadoRelacionAnterior === 'aceptado' ? 'Formulario devuelto correctamente. Ahora quedó nuevamente pendiente.' : 'Formulario rechazado correctamente.'); }); Route::post('/profesional/formularios/{formulario}/asignar-turno-manual', function (Request $request, Formulario $formulario) use ($obtenerAdvertenciasTurno, $verificarConflictoTurno, $formularioFueAceptadoPorOtroProfesional, $enviarCorreoTurno, $registrarLogSeguridadProfesional, $recalcularEstadoGeneralFormulario) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); $profesionalId = (int) ($profesional?->id ?? 0); if (!$profesionalId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $esAsignado = DB::table('profesionales_formularios') ->where('profesional_id', $profesionalId) ->where('formulario_id', (int) $formulario->id) ->exists(); if (!$esAsignado) { return redirect('/profesional/formularios') ->with('profesional_action_error', 'No tenes permiso para asignar turno en este formulario.'); } if ($formularioFueAceptadoPorOtroProfesional((int) $formulario->id, $profesionalId)) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'Este formulario ya fue aceptado por otro profesional.'); } $validated = $request->validate([ 'fecha_turno' => ['required', 'date', 'after_or_equal:today'], 'hora_turno' => ['required', 'date_format:H:i'], 'omitir_restricciones' => ['nullable', 'boolean'], ]); $inicio = \Illuminate\Support\Carbon::parse($validated['fecha_turno'] . ' ' . $validated['hora_turno'] . ':00'); $agenda = Agenda::query()->firstOrCreate( ['profesional_id' => $profesionalId], [ 'estado' => 'Activa', 'duracionturno' => 30, ] ); $duracion = (int) ($agenda->duracionturno ?? 30); $advertencias = $obtenerAdvertenciasTurno((int) $agenda->id, $duracion, $inicio); $errorDisponibilidad = $verificarConflictoTurno((int) $agenda->id, $duracion, $inicio); if ($errorDisponibilidad !== null) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'Ese dia y horario no se encuentra disponible: ' . $errorDisponibilidad) ->withInput(); } if (!empty($advertencias) && !((bool) ($validated['omitir_restricciones'] ?? false))) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'El turno elegido coincide con restricciones de agenda. Revisá las advertencias para decidir si querés asignarlo igualmente.') ->with('formulario_turno_manual_warning', [ 'advertencias' => $advertencias, 'inputs' => [ 'fecha_turno' => $validated['fecha_turno'], 'hora_turno' => $validated['hora_turno'], ], ]) ->withInput(); } $estadoConfirmadoId = EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['confirmado']) ->value('id') ?? 1; Turno::create([ 'inicio' => $inicio, 'correo' => (string) ($formulario->correo ?? ''), 'celular' => (string) ($formulario->celular ?? ''), 'nombrecompleto' => (string) ($formulario->nombrecompleto ?? 'Sin nombre'), 'descripcion' => (string) ($formulario->descripcion ?? 'Turno asignado manualmente.'), 'cliente_id' => $formulario->cliente_id ? (int) $formulario->cliente_id : null, 'estadoturno_id' => (int) $estadoConfirmadoId, 'agenda_id' => (int) $agenda->id, 'profesional_id' => $profesionalId, 'servicio_id' => (int) $formulario->servicio_id, 'modalidad_id' => (int) $formulario->modalidad_id, ]); $profesional->loadMissing('persona'); $nombreProfesional = trim((string) (($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? ''))); $servicioTitulo = (string) (Servicio::query()->where('id', (int) $formulario->servicio_id)->value('titulo') ?? 'Servicio'); $modalidadDescripcion = (string) (Modalidad::query()->where('id', (int) $formulario->modalidad_id)->value('descripcion') ?? 'Modalidad'); $enviarCorreoTurno( 'asignacion', (string) ($formulario->correo ?? ''), (string) ($formulario->nombrecompleto ?? ''), $inicio->copy(), $servicioTitulo, $modalidadDescripcion, $nombreProfesional ); DB::table('profesionales_formularios') ->where('profesional_id', $profesionalId) ->where('formulario_id', (int) $formulario->id) ->update([ 'estado' => 'Aceptado', ]); $recalcularEstadoGeneralFormulario($formulario); $registrarLogSeguridadProfesional( $request, 'Aceptó un caso', 'El profesional ID ' . $profesionalId . ' aceptó el formulario ID ' . $formulario->id . '.' ); $registrarLogSeguridadProfesional( $request, 'Asignó un turno', 'El profesional ID ' . $profesionalId . ' asignó manualmente un turno desde el formulario ID ' . $formulario->id . ' para el ' . $inicio->format('d/m/Y H:i') . '.' ); return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_success', 'Turno asignado correctamente.'); }); Route::post('/profesional/formularios/{formulario}/asignar-turno-automatico', function (Request $request, Formulario $formulario) use ($buscarTurnoAutomatico, $verificarDisponibilidadTurno, $formularioFueAceptadoPorOtroProfesional, $enviarCorreoTurno, $registrarLogSeguridadProfesional, $recalcularEstadoGeneralFormulario) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); $profesionalId = (int) ($profesional?->id ?? 0); if (!$profesionalId) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $esAsignado = DB::table('profesionales_formularios') ->where('profesional_id', $profesionalId) ->where('formulario_id', (int) $formulario->id) ->exists(); if (!$esAsignado) { return redirect('/profesional/formularios') ->with('profesional_action_error', 'No tenes permiso para asignar turno en este formulario.'); } if ($formularioFueAceptadoPorOtroProfesional((int) $formulario->id, $profesionalId)) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'Este formulario ya fue aceptado por otro profesional.'); } $formulario->load(['diasPreferidos', 'horariosPreferidos']); $sugerencia = $buscarTurnoAutomatico($formulario, $profesionalId); if (!$sugerencia || !isset($sugerencia['inicio'], $sugerencia['agenda_id'], $sugerencia['duracion'])) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'No se encontró un turno automático disponible desde mañana.'); } $inicio = $sugerencia['inicio']->copy(); $inicioMinimo = now()->addDay()->startOfDay(); if ($inicio->lt($inicioMinimo)) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'El turno automático debe ser a partir del día siguiente.'); } $errorDisponibilidad = $verificarDisponibilidadTurno((int) $sugerencia['agenda_id'], (int) $sugerencia['duracion'], $inicio->copy()); if ($errorDisponibilidad !== null) { return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_error', 'No se pudo asignar automáticamente el turno: ' . $errorDisponibilidad); } $estadoConfirmadoId = EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['confirmado']) ->value('id') ?? 1; Turno::create([ 'inicio' => $inicio, 'correo' => (string) ($formulario->correo ?? ''), 'celular' => (string) ($formulario->celular ?? ''), 'nombrecompleto' => (string) ($formulario->nombrecompleto ?? 'Sin nombre'), 'descripcion' => (string) ($formulario->descripcion ?? 'Turno asignado automáticamente.'), 'cliente_id' => $formulario->cliente_id ? (int) $formulario->cliente_id : null, 'estadoturno_id' => (int) $estadoConfirmadoId, 'agenda_id' => (int) $sugerencia['agenda_id'], 'profesional_id' => $profesionalId, 'servicio_id' => (int) $formulario->servicio_id, 'modalidad_id' => (int) $formulario->modalidad_id, ]); $profesional->loadMissing('persona'); $nombreProfesional = trim((string) (($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? ''))); $servicioTitulo = (string) (Servicio::query()->where('id', (int) $formulario->servicio_id)->value('titulo') ?? 'Servicio'); $modalidadDescripcion = (string) (Modalidad::query()->where('id', (int) $formulario->modalidad_id)->value('descripcion') ?? 'Modalidad'); $enviarCorreoTurno( 'asignacion', (string) ($formulario->correo ?? ''), (string) ($formulario->nombrecompleto ?? ''), $inicio->copy(), $servicioTitulo, $modalidadDescripcion, $nombreProfesional ); DB::table('profesionales_formularios') ->where('profesional_id', $profesionalId) ->where('formulario_id', (int) $formulario->id) ->update([ 'estado' => 'Aceptado', ]); $recalcularEstadoGeneralFormulario($formulario); $registrarLogSeguridadProfesional( $request, 'Aceptó un caso', 'El profesional ID ' . $profesionalId . ' aceptó el formulario ID ' . $formulario->id . '.' ); $registrarLogSeguridadProfesional( $request, 'Asignó un turno', 'El profesional ID ' . $profesionalId . ' asignó automáticamente un turno desde el formulario ID ' . $formulario->id . ' para el ' . $inicio->format('d/m/Y H:i') . '.' ); return redirect('/profesional/formularios/' . $formulario->id) ->with('profesional_action_success', 'Turno automático asignado para el ' . $inicio->format('d/m/Y') . ' a las ' . $inicio->format('H:i') . '.'); }); Route::get('/profesional/notificaciones', function (Request $request) { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $profesional = Profesional::query() ->where('credencialprofesional_id', $credencialId) ->first(); if (!$profesional) { return redirect('/login/personal')->with('login_error', 'Debes iniciar sesión como profesional.'); } $profesionalId = (int) $profesional->id; $notificaciones = []; // 1. Turnos de hoy $hoy = now()->toDateString(); $turnosHoy = Turno::with(['servicio', 'estadoTurno']) ->where('profesional_id', $profesionalId) ->whereDate('inicio', $hoy) ->orderBy('inicio') ->get(); foreach ($turnosHoy as $turno) { $nombre = trim((string) ($turno->nombrecompleto ?? 'Sin nombre')); $hora = $turno->inicio ? $turno->inicio->format('H:i') : '-'; $servicio = trim((string) ($turno->servicio?->titulo ?? 'Turno')); $estado = trim((string) ($turno->estadoTurno?->descripcion ?? '')); $notificaciones[] = [ 'tipo' => 'turno_hoy', 'titulo' => 'Turno hoy a las ' . $hora . ' — ' . $nombre, 'descripcion' => $servicio . ($estado !== '' ? ' · Estado: ' . $estado : ''), 'fecha' => 'Hoy ' . $hora, 'clave' => base64_encode('turno_hoy|' . ('Turno hoy a las ' . $hora . ' — ' . $nombre) . '|' . ('Hoy ' . $hora)), 'enlace' => '', ]; } // 2. Turnos de mañana $manana = now()->addDay()->toDateString(); $turnosManana = Turno::with(['servicio', 'estadoTurno']) ->where('profesional_id', $profesionalId) ->whereDate('inicio', $manana) ->orderBy('inicio') ->get(); foreach ($turnosManana as $turno) { $nombre = trim((string) ($turno->nombrecompleto ?? 'Sin nombre')); $hora = $turno->inicio ? $turno->inicio->format('H:i') : '-'; $servicio = trim((string) ($turno->servicio?->titulo ?? 'Turno')); $estado = trim((string) ($turno->estadoTurno?->descripcion ?? '')); $notificaciones[] = [ 'tipo' => 'turno_manana', 'titulo' => 'Turno mañana a las ' . $hora . ' — ' . $nombre, 'descripcion' => $servicio . ($estado !== '' ? ' · Estado: ' . $estado : ''), 'fecha' => 'Mañana ' . $hora, 'clave' => base64_encode('turno_manana|' . ('Turno mañana a las ' . $hora . ' — ' . $nombre) . '|' . ('Mañana ' . $hora)), 'enlace' => '', ]; } // 3. Turnos cancelados recientes (últimos 7 días) $estadoCanceladoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['cancelado']) ->value('id') ?? 0); if ($estadoCanceladoId > 0) { $turnosCancelados = Turno::with(['servicio']) ->where('profesional_id', $profesionalId) ->where('estadoturno_id', $estadoCanceladoId) ->where('updated_at', '>=', now()->subDays(7)) ->orderByDesc('updated_at') ->get(); foreach ($turnosCancelados as $turno) { $nombre = trim((string) ($turno->nombrecompleto ?? 'Sin nombre')); $fecha = $turno->inicio ? $turno->inicio->format('d/m/Y H:i') : '-'; $servicio = trim((string) ($turno->servicio?->titulo ?? 'Turno')); $notificaciones[] = [ 'tipo' => 'turno_cancelado', 'titulo' => 'Turno cancelado — ' . $nombre, 'descripcion' => $servicio . ' · Fecha del turno: ' . $fecha, 'fecha' => $turno->updated_at ? $turno->updated_at->format('d/m/Y') : '-', 'clave' => base64_encode('turno_cancelado|' . ('Turno cancelado — ' . $nombre) . '|' . ($turno->updated_at ? $turno->updated_at->format('d/m/Y') : '-')), 'enlace' => '', ]; } } return view('profesional.notificaciones', [ 'notificaciones' => $notificaciones, ]); }); Route::get('/administrador/dashboard', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $contenidoWeb = ContenidoWeb::latest('id')->first(); $quienesSomos = $contenidoWeb?->quienessomos; $profesionales = Profesional::with(['persona.Foto', 'profesion', 'profesiones']) ->where('baja_id', 1) ->get(); $servicios = Servicio::with('foto') ->where('estado', 'activo') ->where('visibleenweb', 'si') ->get(); $profesiones = Profesion::query() ->where('visible_en_formulario', true) ->orderBy('titulo') ->get(); $modalidades = Modalidad::query() ->orderBy('descripcion') ->get(); return view('administrador.dashboard', [ 'quienesSomos' => $quienesSomos, 'profesionales' => $profesionales, 'servicios' => $servicios, 'profesiones' => $profesiones, 'modalidades' => $modalidades, ]); }); Route::get('/administrador/perfil', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $administrador = Administrador::with(['persona.telefonos', 'credencial']) ->where('credencialprofesional_id', $credencialId) ->first(); if (!$administrador) { return redirect('/administrador/dashboard') ->with('admin_action_error', 'No se encontró el perfil de administradora asociado a la sesión actual.'); } return view('administrador.perfil', [ 'administrador' => $administrador, 'telefonoActual' => $administrador->persona?->telefonos?->first(), ]); }); Route::put('/administrador/perfil', function (Request $request) use ($validarSesionAdmin, $registrarLogSeguridad) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $administrador = Administrador::with(['persona.telefonos', 'credencial']) ->where('credencialprofesional_id', $credencialId) ->first(); if (!$administrador) { return redirect('/administrador/dashboard') ->with('admin_action_error', 'No se encontró el perfil de administradora asociado a la sesión actual.'); } $validated = $request->validate([ 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'correo' => ['required', 'email', 'max:255'], 'telefono' => ['nullable', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'usuario' => ['required', 'string', 'max:100', 'unique:credencialesprofesionales,usuario,' . $administrador->credencialprofesional_id], 'contra_actual' => ['required', 'string'], 'contra' => ['nullable', 'string', 'min:6', 'confirmed'], ]); $nombreAnterior = (string) ($administrador->persona?->nombre ?? ''); $apellidoAnterior = (string) ($administrador->persona?->apellido ?? ''); $correoAnterior = (string) ($administrador->correo ?? ''); $telefonoAnterior = (string) ($administrador->persona?->telefonos?->first()?->telefono ?? ''); $usuarioAnterior = (string) ($administrador->credencial?->usuario ?? ''); $contraActualIngresada = (string) ($validated['contra_actual'] ?? ''); $contraActualGuardada = (string) ($administrador->credencial?->contra ?? ''); $contraActualValida = $contraActualIngresada !== '' && ($contraActualIngresada === $contraActualGuardada || Hash::check($contraActualIngresada, $contraActualGuardada)); if (!$contraActualValida) { $registrarLogSeguridad( $request, 'Cambio de contraseña frustrado', 'La administradora ID ' . (int) $administrador->id . ' ingresó una contraseña actual inválida al intentar modificar sus credenciales.' ); return back() ->withErrors(['contra_actual' => 'La contraseña actual no es correcta.']) ->withInput($request->except(['contra_actual', 'contra', 'contra_confirmation'])); } DB::transaction(function () use ($administrador, $validated): void { $administrador->persona?->update([ 'nombre' => $validated['nombre'], 'apellido' => $validated['apellido'], ]); if ($administrador->persona && !blank($validated['telefono'] ?? null)) { $telefonoExistente = $administrador->persona->telefonos()->first(); if ($telefonoExistente) { $telefonoExistente->update([ 'telefono' => $validated['telefono'], ]); } else { $telefonoNuevo = Telefono::create([ 'telefono' => $validated['telefono'], ]); DB::table('personas_telefonos')->insertOrIgnore([ 'dni' => (string) $administrador->dni, 'persona_id' => (int) $administrador->persona->id, 'telefono_id' => (int) $telefonoNuevo->id, 'created_at' => now(), 'updated_at' => now(), ]); } } $administrador->update([ 'correo' => $validated['correo'], ]); $datosCredencial = [ 'usuario' => $validated['usuario'], ]; if (!empty($validated['contra'])) { $datosCredencial['contra'] = Hash::make($validated['contra']); } $administrador->credencial?->update($datosCredencial); }); $nombreCompleto = trim($validated['nombre'] . ' ' . $validated['apellido']); $request->session()->put([ 'personal_nombre' => $nombreCompleto, 'personal_apellido' => $validated['apellido'], 'personal_usuario' => $validated['usuario'], ]); $registrarLogSeguridad( $request, 'Edito los datos del administrador', 'La administradora ID ' . (int) $administrador->id . ' actualizó su perfil. Nombre: "' . $nombreAnterior . '" -> "' . $validated['nombre'] . '", Apellido: "' . $apellidoAnterior . '" -> "' . $validated['apellido'] . '", Correo: "' . $correoAnterior . '" -> "' . $validated['correo'] . '", Teléfono: "' . $telefonoAnterior . '" -> "' . ($validated['telefono'] ?? '') . '", Usuario: "' . $usuarioAnterior . '" -> "' . $validated['usuario'] . '".' ); if (!empty($validated['contra'])) { $registrarLogSeguridad( $request, 'Cambio de contraseña exitoso', 'La administradora ID ' . (int) $administrador->id . ' cambió su contraseña desde su perfil.' ); } return redirect('/administrador/perfil') ->with('admin_action_success', 'Datos de administradora actualizados correctamente.'); }); Route::post('/administrador/perfil/pregunta-secreta', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $administrador = Administrador::with('credencial') ->where('credencialprofesional_id', $credencialId) ->first(); if (!$administrador) { return redirect('/administrador/dashboard') ->with('admin_action_error', 'No se encontró el perfil de administradora asociado a la sesión actual.'); } $validated = $request->validate([ 'contra_actual_secreta' => ['required', 'string'], 'pregunta_secreta' => ['required', 'string', 'max:255'], 'respuesta_secreta' => ['required', 'string', 'max:255'], ]); $contraActualIngresada = (string) ($validated['contra_actual_secreta'] ?? ''); $contraActualGuardada = (string) ($administrador->credencial?->contra ?? ''); $contraActualValida = $contraActualIngresada !== '' && ($contraActualIngresada === $contraActualGuardada || Hash::check($contraActualIngresada, $contraActualGuardada)); if (!$contraActualValida) { return back() ->withErrors(['contra_actual_secreta' => 'La contraseña actual no es correcta.']) ->withInput($request->except(['contra_actual_secreta', 'respuesta_secreta'])); } $administrador->update([ 'pregunta_secreta_hash' => mb_strtolower(trim((string) $validated['pregunta_secreta'])), 'respuesta_secreta_hash' => Hash::make(mb_strtolower(trim((string) $validated['respuesta_secreta']))), ]); return redirect('/administrador/perfil') ->with('admin_action_success', 'Pregunta y respuesta secreta actualizadas correctamente.'); }); Route::get('/administrador/logs', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $query = LogSeguridad::query(); $filtroAccion = trim((string) $request->query('accion', '')); $filtroDesde = trim((string) $request->query('desde', '')); $filtroHasta = trim((string) $request->query('hasta', '')); $filtroTexto = trim((string) $request->query('q', '')); if ($filtroAccion !== '') { $query->where('accion_descripcion', $filtroAccion); } if ($filtroDesde !== '') { $query->whereDate('fechahora', '>=', $filtroDesde); } if ($filtroHasta !== '') { $query->whereDate('fechahora', '<=', $filtroHasta); } if ($filtroTexto !== '') { $query->where(function ($q) use ($filtroTexto): void { $q->where('descripcion', 'like', '%' . $filtroTexto . '%') ->orWhere('IPorigen', 'like', '%' . $filtroTexto . '%') ->orWhere('rol', 'like', '%' . $filtroTexto . '%') ->orWhere('accion_descripcion', 'like', '%' . $filtroTexto . '%') ->orWhere('responsable_nombre', 'like', '%' . $filtroTexto . '%'); }); } $logs = $query ->latest('id') ->paginate(10) ->withQueryString(); $accionesDisponibles = LogSeguridad::query() ->whereNotNull('accion_descripcion') ->distinct() ->orderBy('accion_descripcion') ->pluck('accion_descripcion'); return view('administrador.logs', [ 'logs' => $logs, 'accionesDisponibles' => $accionesDisponibles, ]); }); Route::get('/administrador/asistente-consultas', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $pendientes = AsistenteSinRespuesta::query() ->orderByDesc('created_at') ->paginate(15) ->withQueryString(); $totalSinRevisar = AsistenteSinRespuesta::query() ->where('revisado', false) ->count(); return view('administrador.asistente-consultas', [ 'pendientes' => $pendientes, 'totalSinRevisar' => $totalSinRevisar, ]); }); Route::post('/administrador/asistente-consultas/{id}/marcar-revisado', function (Request $request, int $id) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } AsistenteSinRespuesta::query()->where('id', $id)->update(['revisado' => true]); return back()->with('admin_action_success', 'Consulta marcada como revisada.'); }); Route::delete('/administrador/asistente-consultas/{id}', function (Request $request, int $id) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } AsistenteSinRespuesta::query()->where('id', $id)->delete(); return back()->with('admin_action_success', 'Consulta eliminada.'); }); Route::get('/administrador/fallas', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $query = Error::query(); $filtroCodigo = trim((string) $request->query('codigo', '')); $filtroDesde = trim((string) $request->query('desde', '')); $filtroHasta = trim((string) $request->query('hasta', '')); $filtroTexto = trim((string) $request->query('q', '')); if ($filtroCodigo !== '') { $query->where('codigo', $filtroCodigo); } if ($filtroDesde !== '') { $query->whereDate('fecha_hora', '>=', $filtroDesde); } if ($filtroHasta !== '') { $query->whereDate('fecha_hora', '<=', $filtroHasta); } if ($filtroTexto !== '') { $query->where(function ($q) use ($filtroTexto): void { $q->where('mensaje', 'like', '%' . $filtroTexto . '%') ->orWhere('url', 'like', '%' . $filtroTexto . '%') ->orWhere('track_trace', 'like', '%' . $filtroTexto . '%'); }); } $fallas = $query ->latest('id') ->paginate(10) ->withQueryString(); $codigosDisponibles = Error::query() ->whereNotNull('codigo') ->where('codigo', '<>', '') ->distinct() ->orderBy('codigo') ->pluck('codigo'); return view('administrador.fallas', [ 'fallas' => $fallas, 'codigosDisponibles' => $codigosDisponibles, ]); }); Route::get('/administrador/logs/exportar-json', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $query = LogSeguridad::query(); $filtroAccion = trim((string) $request->query('accion', '')); $filtroDesde = trim((string) $request->query('desde', '')); $filtroHasta = trim((string) $request->query('hasta', '')); $filtroTexto = trim((string) $request->query('q', '')); if ($filtroAccion !== '') { $query->where('accion_descripcion', $filtroAccion); } if ($filtroDesde !== '') { $query->whereDate('fechahora', '>=', $filtroDesde); } if ($filtroHasta !== '') { $query->whereDate('fechahora', '<=', $filtroHasta); } if ($filtroTexto !== '') { $query->where(function ($q) use ($filtroTexto): void { $q->where('descripcion', 'like', '%' . $filtroTexto . '%') ->orWhere('IPorigen', 'like', '%' . $filtroTexto . '%') ->orWhere('rol', 'like', '%' . $filtroTexto . '%') ->orWhere('accion_descripcion', 'like', '%' . $filtroTexto . '%') ->orWhere('responsable_nombre', 'like', '%' . $filtroTexto . '%'); }); } $logs = $query ->latest('id') ->get() ->map(function ($log) { return [ 'id' => $log->id, 'fecha_hora' => $log->fechahora ? \Illuminate\Support\Carbon::parse($log->fechahora)->format('Y-m-d H:i:s') : null, 'accion_id' => $log->accion_id, 'accion' => $log->accion_descripcion, 'rol' => $log->rol, 'ip_origen' => $log->IPorigen, 'responsable' => $log->responsable_nombre, 'descripcion' => $log->descripcion, ]; }) ->values(); $nombreArchivo = 'logs-seguridad-' . now()->format('Ymd-His') . '.json'; return response()->json([ 'generado_en' => now()->format('Y-m-d H:i:s'), 'filtros_aplicados' => [ 'accion' => $filtroAccion !== '' ? $filtroAccion : null, 'desde' => $filtroDesde !== '' ? $filtroDesde : null, 'hasta' => $filtroHasta !== '' ? $filtroHasta : null, 'texto' => $filtroTexto !== '' ? $filtroTexto : null, ], 'total_registros' => $logs->count(), 'logs' => $logs, ], 200, [ 'Content-Disposition' => 'attachment; filename="' . $nombreArchivo . '"', ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); }); Route::get('/administrador/contenido-web', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } return redirect('/administrador/contenido/quienes-somos'); }); Route::get('/administrador/contenido/quienes-somos', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $contenidoWeb = ContenidoWeb::latest('id')->first(); return view('administrador.contenido-quienes-somos', [ 'contenidoWeb' => $contenidoWeb, ]); }); Route::get('/administrador/contenido/profesiones', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesiones = Profesion::query() ->orderBy('titulo') ->get(); return view('administrador.contenido-profesiones', [ 'profesiones' => $profesiones, ]); }); Route::get('/administrador/contenido/servicios', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $servicios = Servicio::with('profesion') ->orderBy('titulo') ->paginate(15) ->withQueryString(); return view('administrador.contenido-servicios', [ 'servicios' => $servicios, ]); }); Route::get('/administrador/contenido/asistente-virtual', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } return redirect('/administrador/contenido/asistente-virtual/ver-faqs'); }); Route::get('/administrador/contenido/asistente-virtual/agregar-faq', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_BURBUJA], [ 'palabras_clave' => [], 'respuesta' => '¡Hola!, ¿te puedo ayudar en algo?', 'orden' => 1, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_PANEL_INICIO], [ 'palabras_clave' => [], 'respuesta' => 'Hola, soy el asistente virtual. Puedo ayudarte con servicios, profesionales, turnos, ubicación y contacto.', 'orden' => 2, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_ERROR], [ 'palabras_clave' => [], 'respuesta' => 'Lo siento, no tengo una respuesta exacta para eso.', 'orden' => 3, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_NOMBRE], [ 'palabras_clave' => [], 'respuesta' => 'Clara', 'orden' => 4, 'activo' => true, ] ); return view('administrador.contenido-asistente-agregar-faq'); }); Route::get('/administrador/contenido/asistente-virtual/agregar-chips', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $chipsAsistenteAdmin = FaqAsistente::query() ->where('intencion', FAQ_INTENCION_CHIPS) ->orderBy('orden') ->orderBy('id') ->get(); return view('administrador.contenido-asistente-agregar-chips', [ 'chipsAsistenteAdmin' => $chipsAsistenteAdmin, ]); }); Route::get('/administrador/contenido/asistente-virtual/ver-faqs', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $filtroIntencion = mb_strtolower(trim((string) $request->query('intencion', ''))); $perPage = (int) $request->query('per_page', 10); if (!in_array($perPage, [10, 20, 50], true)) { $perPage = 10; } FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_BURBUJA], [ 'palabras_clave' => [], 'respuesta' => '¡Hola!, ¿te puedo ayudar en algo?', 'orden' => 1, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_PANEL_INICIO], [ 'palabras_clave' => [], 'respuesta' => 'Hola, soy el asistente virtual. Puedo ayudarte con servicios, profesionales, turnos, ubicación y contacto.', 'orden' => 2, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_ERROR], [ 'palabras_clave' => [], 'respuesta' => 'Lo siento, no tengo una respuesta exacta para eso.', 'orden' => 3, 'activo' => true, ] ); FaqAsistente::query()->firstOrCreate( ['intencion' => FAQ_INTENCION_NOMBRE], [ 'palabras_clave' => [], 'respuesta' => 'Clara', 'orden' => 4, 'activo' => true, ] ); $faqsAsistenteQuery = FaqAsistente::query() ->where(function ($query): void { $query->whereNull('intencion') ->orWhere('intencion', '!=', FAQ_INTENCION_CHIPS); }); if ($filtroIntencion !== '') { $faqsAsistenteQuery->whereRaw('LOWER(COALESCE(intencion, "")) like ?', ['%' . $filtroIntencion . '%']); } $faqsAsistente = $faqsAsistenteQuery ->orderBy('orden') ->orderBy('id') ->paginate($perPage) ->withQueryString(); return view('administrador.contenido-asistente-ver-faqs', [ 'faqsAsistente' => $faqsAsistente, 'filtroIntencion' => $filtroIntencion, 'perPage' => $perPage, ]); }); Route::get('/administrador/emails', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $tipos = [ 'turno_otorgado' => [ 'titulo' => 'Turno otorgado', 'mensaje_inicio' => 'Se asigno tu turno con los siguientes datos:', 'mensaje_final' => 'Si necesitas reprogramar o cancelar, por favor comunicate por WhatsApp al 343-4632444', ], 'turno_cancelado' => [ 'titulo' => 'Turno cancelado', 'mensaje_inicio' => 'Te informamos que tu turno fue cancelado con los siguientes datos:', 'mensaje_final' => 'Si necesitas coordinar un nuevo turno, por favor comunicate por WhatsApp al 343-4632444', ], 'turno_reprogramado' => [ 'titulo' => 'Turno reprogramado', 'mensaje_inicio' => 'Te informamos que tu turno fue reprogramado con los siguientes datos:', 'mensaje_final' => 'Si tenes dudas sobre este cambio, por favor comunicate por WhatsApp al 343-4632444', ], ]; foreach ($tipos as $tipo => $defecto) { Notificacion::query()->firstOrCreate( ['tipo' => $tipo], [ 'mensaje_inicio' => $defecto['mensaje_inicio'], 'mensaje_final' => $defecto['mensaje_final'], ] ); } $notificaciones = Notificacion::query() ->whereIn('tipo', array_keys($tipos)) ->get() ->keyBy('tipo'); return view('administrador.emails', [ 'tipos' => $tipos, 'notificaciones' => $notificaciones, ]); }); Route::post('/administrador/emails', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validado = $request->validate([ 'turno_otorgado_mensaje_inicio' => ['required', 'string', 'max:5000'], 'turno_otorgado_mensaje_final' => ['required', 'string', 'max:5000'], 'turno_cancelado_mensaje_inicio' => ['required', 'string', 'max:5000'], 'turno_cancelado_mensaje_final' => ['required', 'string', 'max:5000'], 'turno_reprogramado_mensaje_inicio' => ['required', 'string', 'max:5000'], 'turno_reprogramado_mensaje_final' => ['required', 'string', 'max:5000'], ]); $tipos = ['turno_otorgado', 'turno_cancelado', 'turno_reprogramado']; foreach ($tipos as $tipo) { Notificacion::query()->updateOrCreate( ['tipo' => $tipo], [ 'mensaje_inicio' => $validado[$tipo . '_mensaje_inicio'], 'mensaje_final' => $validado[$tipo . '_mensaje_final'], ] ); } return redirect('/administrador/emails') ->with('admin_action_success', 'Los textos de emails se actualizaron correctamente.'); }); Route::get('/administrador/bugs', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $bugs = Bug::query() ->with('fotoBug') ->orderByDesc('id') ->get(); return view('administrador.reportes-bugs', [ 'bugs' => $bugs, ]); }); Route::post('/administrador/bugs/{bug}/marcar-visto', function (Request $request, Bug $bug) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $estadoActual = mb_strtolower(trim((string) $bug->estado)); if ($estadoActual === 'pendiente') { $bug->update([ 'estado' => 'Visto', ]); return redirect('/administrador/bugs') ->with('admin_action_success', 'El reporte se marcó como visto.'); } return redirect('/administrador/bugs') ->with('admin_action_info', 'El reporte ya estaba en estado visto.'); }); Route::post('/administrador/contenido-web', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'quienessomos' => ['required', 'string', 'max:5000'], ]); $contenidoWeb = ContenidoWeb::latest('id')->first(); if ($contenidoWeb) { $contenidoWeb->update([ 'quienessomos' => $validated['quienessomos'], ]); } else { ContenidoWeb::create([ 'quienessomos' => $validated['quienessomos'], ]); } return redirect('/administrador/contenido/quienes-somos') ->with('admin_action_success', 'Se actualizó la sección Quienes Somos correctamente.'); }); Route::post('/administrador/contenido-web/faqs', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'intencion' => ['nullable', 'string', 'max:100'], 'palabras_clave' => ['nullable', 'string', 'max:1000'], 'respuesta' => ['required', 'string', 'max:5000'], 'orden' => ['required', 'integer', 'min:0', 'max:65535'], 'activo' => ['required', 'in:0,1'], ]); $palabrasClave = collect(explode(',', (string) ($validated['palabras_clave'] ?? ''))) ->map(fn ($palabra) => trim($palabra)) ->filter(fn ($palabra) => $palabra !== '') ->unique() ->values() ->all(); $intencionNormalizada = mb_strtolower(trim((string) ($validated['intencion'] ?? ''))); $esMensajeSistema = in_array($intencionNormalizada, [ FAQ_INTENCION_BURBUJA, FAQ_INTENCION_PANEL_INICIO, FAQ_INTENCION_ERROR, FAQ_INTENCION_NOMBRE, FAQ_INTENCION_CHIPS, ], true); if (!$esMensajeSistema && empty($palabrasClave)) { return back() ->withErrors(['palabras_clave' => 'Ingresá al menos una palabra clave válida para respuestas del chat.']) ->withInput(); } FaqAsistente::create([ 'intencion' => $intencionNormalizada !== '' ? $intencionNormalizada : null, 'palabras_clave' => $palabrasClave, 'respuesta' => $validated['respuesta'], 'orden' => (int) $validated['orden'], 'activo' => (bool) $validated['activo'], ]); if ($intencionNormalizada === FAQ_INTENCION_CHIPS) { return redirect('/administrador/contenido/asistente-virtual/agregar-chips') ->with('admin_action_success', 'ui_chip creado correctamente.'); } return redirect('/administrador/contenido/asistente-virtual/agregar-faq') ->with('admin_action_success', 'FAQ del asistente creada correctamente.'); }); Route::put('/administrador/contenido-web/faqs/{faqAsistente}', function (Request $request, FaqAsistente $faqAsistente) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'intencion' => ['nullable', 'string', 'max:100'], 'palabras_clave' => ['nullable', 'string', 'max:1000'], 'respuesta' => ['required', 'string', 'max:5000'], 'orden' => ['required', 'integer', 'min:0', 'max:65535'], 'activo' => ['required', 'in:0,1'], ]); $palabrasClave = collect(explode(',', (string) ($validated['palabras_clave'] ?? ''))) ->map(fn ($palabra) => trim($palabra)) ->filter(fn ($palabra) => $palabra !== '') ->unique() ->values() ->all(); $intencionNormalizada = mb_strtolower(trim((string) ($validated['intencion'] ?? ''))); $esMensajeSistema = in_array($intencionNormalizada, [ FAQ_INTENCION_BURBUJA, FAQ_INTENCION_PANEL_INICIO, FAQ_INTENCION_ERROR, FAQ_INTENCION_NOMBRE, FAQ_INTENCION_CHIPS, ], true); if (!$esMensajeSistema && empty($palabrasClave)) { return back() ->withErrors(['palabras_clave' => 'Ingresá al menos una palabra clave válida para respuestas del chat.']) ->withInput(); } $faqAsistente->update([ 'intencion' => $intencionNormalizada !== '' ? $intencionNormalizada : null, 'palabras_clave' => $palabrasClave, 'respuesta' => $validated['respuesta'], 'orden' => (int) $validated['orden'], 'activo' => (bool) $validated['activo'], ]); if ($intencionNormalizada === FAQ_INTENCION_CHIPS) { return redirect('/administrador/contenido/asistente-virtual/agregar-chips') ->with('admin_action_success', 'ui_chip actualizado correctamente.'); } return redirect('/administrador/contenido/asistente-virtual/ver-faqs') ->with('admin_action_success', 'FAQ del asistente actualizada correctamente.'); }); Route::delete('/administrador/contenido-web/faqs/{faqAsistente}', function (Request $request, FaqAsistente $faqAsistente) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $esChip = mb_strtolower(trim((string) ($faqAsistente->intencion ?? ''))) === FAQ_INTENCION_CHIPS; $faqAsistente->delete(); if ($esChip) { return redirect('/administrador/contenido/asistente-virtual/agregar-chips') ->with('admin_action_success', 'ui_chip borrado correctamente.'); } return redirect('/administrador/contenido/asistente-virtual/ver-faqs') ->with('admin_action_success', 'FAQ del asistente borrada correctamente.'); }); Route::get('/administrador/profesiones/crear', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } return view('administrador.crear-profesion'); }); Route::post('/administrador/profesiones', function (Request $request) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'titulo' => ['required', 'string', 'max:255', 'unique:profesiones,titulo'], 'visible_en_formulario' => ['required', 'in:0,1'], ]); $profesionCreada = Profesion::create([ 'titulo' => $validated['titulo'], 'visible_en_formulario' => (bool) $validated['visible_en_formulario'], ]); $registrarLogSeguridad( $request, 'Creación nueva profesion', 'Se creó la profesión ID ' . $profesionCreada->id . ' (' . $profesionCreada->titulo . ').' ); return redirect('/administrador/contenido/profesiones') ->with('admin_action_success', 'Profesión creada correctamente.'); }); Route::get('/administrador/profesiones/{profesion}/editar', function (Request $request, Profesion $profesion) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } return view('administrador.editar-profesion', [ 'profesion' => $profesion, ]); }); Route::put('/administrador/profesiones/{profesion}', function (Request $request, Profesion $profesion) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'titulo' => ['required', 'string', 'max:255', 'unique:profesiones,titulo,' . $profesion->id], 'visible_en_formulario' => ['required', 'in:0,1'], ]); $tituloAnterior = (string) $profesion->titulo; $visibleAnterior = (bool) $profesion->visible_en_formulario; $profesion->update([ 'titulo' => $validated['titulo'], 'visible_en_formulario' => (bool) $validated['visible_en_formulario'], ]); $visibleNuevo = (bool) $profesion->visible_en_formulario; if ($visibleAnterior && !$visibleNuevo) { $registrarLogSeguridad( $request, 'Baja profesion', 'Se dio de baja la profesión ID ' . $profesion->id . ' (' . $profesion->titulo . ').' ); } elseif (!$visibleAnterior && $visibleNuevo) { $registrarLogSeguridad( $request, 'Alta profesion', 'Se dio de alta la profesión ID ' . $profesion->id . ' (' . $profesion->titulo . ').' ); } else { $registrarLogSeguridad( $request, 'Edición datos profesion', 'Se editó la profesión ID ' . $profesion->id . '. Título: "' . $tituloAnterior . '" -> "' . $profesion->titulo . '".' ); } return redirect('/administrador/contenido/profesiones') ->with('admin_action_success', 'Profesión actualizada correctamente.'); }); Route::get('/administrador/servicios/crear', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesiones = Profesion::query() ->orderBy('titulo') ->get(); return view('administrador.crear-servicio', [ 'profesiones' => $profesiones, ]); }); Route::post('/administrador/servicios', function (Request $request) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $contenidoWeb = ContenidoWeb::latest('id')->first(); if (!$contenidoWeb) { $contenidoWeb = ContenidoWeb::create([ 'quienessomos' => '', ]); } $validated = $request->validate([ 'titulo' => ['required', 'string', 'max:255'], 'descripcion' => ['required', 'string', 'max:3000'], 'estado' => ['required', 'in:activo,inactivo'], 'visibleenweb' => ['required', 'in:si,no'], 'profesion_id' => ['required', 'exists:profesiones,id'], 'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], ]); $fotoDefaultId = Foto::query() ->where('ruta', 'storage/fotos/Servicio.jpg') ->value('id'); if (!$fotoDefaultId) { $fotoDefaultId = Foto::query()->create([ 'extension' => 'jpg', 'tamanio_bytes' => 0, 'nombre' => 'Servicio', 'mime_type' => 'image/jpeg', 'ruta' => 'storage/fotos/Servicio.jpg', ])->id; } $datos = [ 'titulo' => $validated['titulo'], 'descripcion' => $validated['descripcion'], 'estado' => $validated['estado'], 'visibleenweb' => $validated['visibleenweb'], 'contenidoweb_id' => (int) $contenidoWeb->id, 'profesion_id' => (int) $validated['profesion_id'], 'foto_id' => (int) $fotoDefaultId, ]; $fotoFile = $request->file('foto'); if ($fotoFile) { $directorio = public_path('images/servicios'); File::ensureDirectoryExists($directorio); $extension = $fotoFile->getClientOriginalExtension(); $mimeType = $fotoFile->getClientMimeType(); $tamanioBytes = $fotoFile->getSize(); $nombreArchivo = uniqid('serv_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $foto = Foto::create([ 'extension' => $extension, 'nombre' => pathinfo($nombreArchivo, PATHINFO_FILENAME), 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'ruta' => 'images/servicios/' . $nombreArchivo, ]); $datos['foto_id'] = $foto->id; } $servicioCreado = Servicio::create($datos); $registrarLogSeguridad( $request, 'Creación nuevo servicio', 'Se creó el servicio ID ' . $servicioCreado->id . ' (' . $servicioCreado->titulo . ') para la profesión ID ' . (int) $servicioCreado->profesion_id . '.' ); return redirect('/administrador/contenido/servicios') ->with('admin_action_success', 'Servicio creado correctamente.'); }); Route::get('/administrador/servicios/{servicio}/editar', function (Request $request, Servicio $servicio) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesiones = Profesion::query() ->orderBy('titulo') ->get(); return view('administrador.editar-servicio', [ 'servicio' => $servicio, 'profesiones' => $profesiones, ]); }); Route::put('/administrador/servicios/{servicio}', function (Request $request, Servicio $servicio) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'titulo' => ['required', 'string', 'max:255'], 'descripcion' => ['required', 'string', 'max:3000'], 'estado' => ['required', 'in:activo,inactivo'], 'visibleenweb' => ['required', 'in:si,no'], 'profesion_id' => ['required', 'exists:profesiones,id'], 'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], ]); $tituloAnterior = (string) $servicio->titulo; $estadoAnterior = mb_strtolower(trim((string) $servicio->estado)); $datos = [ 'titulo' => $validated['titulo'], 'descripcion' => $validated['descripcion'], 'estado' => $validated['estado'], 'visibleenweb' => $validated['visibleenweb'], 'profesion_id' => (int) $validated['profesion_id'], ]; $fotoFile = $request->file('foto'); if ($fotoFile) { $directorio = public_path('images/servicios'); File::ensureDirectoryExists($directorio); $extension = $fotoFile->getClientOriginalExtension(); $mimeType = $fotoFile->getClientMimeType(); $tamanioBytes = $fotoFile->getSize(); $nombreArchivo = uniqid('serv_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $foto = Foto::create([ 'extension' => $extension, 'nombre' => pathinfo($nombreArchivo, PATHINFO_FILENAME), 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'ruta' => 'images/servicios/' . $nombreArchivo, ]); $datos['foto_id'] = $foto->id; } $servicio->update($datos); $estadoNuevo = mb_strtolower(trim((string) $servicio->estado)); if ($estadoAnterior === 'activo' && $estadoNuevo !== 'activo') { $registrarLogSeguridad( $request, 'Baja servicio', 'Se dio de baja el servicio ID ' . $servicio->id . ' (' . $servicio->titulo . ').' ); } elseif ($estadoAnterior !== 'activo' && $estadoNuevo === 'activo') { $registrarLogSeguridad( $request, 'Alta servicio', 'Se dio de alta el servicio ID ' . $servicio->id . ' (' . $servicio->titulo . ').' ); } else { $registrarLogSeguridad( $request, 'Edición datos servicio', 'Se editó el servicio ID ' . $servicio->id . '. Título: "' . $tituloAnterior . '" -> "' . $servicio->titulo . '".' ); } return redirect('/administrador/contenido/servicios') ->with('admin_action_success', 'Servicio actualizado correctamente.'); }); Route::get('/administrador/profesionales', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $filtroTexto = trim((string) $request->query('q', '')); $filtroEstado = trim((string) $request->query('estado', '')); $profesionales = Profesional::with([ 'persona', 'profesion', 'profesiones', 'credencialProfesional', ]) ->when($filtroTexto !== '', function ($query) use ($filtroTexto) { $query->where(function ($subQuery) use ($filtroTexto): void { $subQuery->where('dni', 'like', '%' . $filtroTexto . '%') ->orWhere('correo', 'like', '%' . $filtroTexto . '%') ->orWhere('matricula', 'like', '%' . $filtroTexto . '%') ->orWhereHas('persona', function ($qPersona) use ($filtroTexto): void { $qPersona->where('nombre', 'like', '%' . $filtroTexto . '%') ->orWhere('apellido', 'like', '%' . $filtroTexto . '%') ->orWhere('dni', 'like', '%' . $filtroTexto . '%'); }) ->orWhereHas('profesion', function ($qProfesion) use ($filtroTexto): void { $qProfesion->where('titulo', 'like', '%' . $filtroTexto . '%'); }); }); }) ->when($filtroEstado === 'activos', fn ($query) => $query->where('baja_id', 1)) ->when($filtroEstado === 'inactivos', fn ($query) => $query->where('baja_id', '!=', 1)) ->orderBy('id') ->paginate(10) ->withQueryString(); return view('administrador.profesionales', [ 'profesionales' => $profesionales, ]); }); Route::post('/administrador/profesionales/{profesional}/baja', function (Request $request, Profesional $profesional) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $estabaDeBaja = (int) $profesional->baja_id !== 1; $nombreCompleto = trim(($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? '')); DB::transaction(function () use ($profesional): void { if ((int) $profesional->baja_id !== 1) { $profesional->baja_id = 1; $profesional->save(); return; } $baja = Baja::create([ 'descripcion' => 'Baja realizada por administrador', ]); $profesional->baja_id = $baja->id; $profesional->save(); }); if ($estabaDeBaja) { $registrarLogSeguridad( $request, 'Alta profesional', 'Se dio de alta al profesional ID ' . $profesional->id . ' (' . $nombreCompleto . ').' ); return redirect('/administrador/profesionales') ->with('admin_action_success', 'Profesional reactivado correctamente.'); } $registrarLogSeguridad( $request, 'Baja profesional', 'Se dio de baja al profesional ID ' . $profesional->id . ' (' . $nombreCompleto . ').' ); return redirect('/administrador/profesionales') ->with('admin_action_success', 'Profesional dado de baja correctamente.'); }); Route::get('/administrador/profesionales/nuevo', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesiones = Profesion::query() ->orderBy('titulo') ->get(); $servicios = Servicio::query() ->where('estado', 'activo') ->orderBy('titulo') ->get(); return view('administrador.nuevo-profesional', [ 'profesiones' => $profesiones, 'servicios' => $servicios, ]); }); Route::get('/administrador/profesionales/buscar-persona', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $dni = trim((string) $request->query('dni', '')); if (strlen($dni) < 3) { return response()->json(['encontrada' => false, 'persona' => null]); } $persona = Persona::where('dni', $dni)->first(); if (!$persona) { return response()->json(['encontrada' => false, 'persona' => null]); } $celular = trim((string) ($persona->telefonos()->value('telefono') ?? '')); return response()->json([ 'encontrada' => true, 'persona' => [ 'nombre' => $persona->nombre, 'apellido' => $persona->apellido, 'cuil' => $persona->cuil, 'celular' => $celular, 'fechanac' => $persona->fechanac, ], ]); }); Route::get('/administrador/profesionales/{profesional}/editar', function (Request $request, Profesional $profesional) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesiones = Profesion::query() ->orderBy('titulo') ->get(); $servicios = Servicio::query() ->where('estado', 'activo') ->orderBy('titulo') ->get(); $serviciosSeleccionados = $profesional->servicios() ->pluck('servicios.id') ->all(); return view('administrador.editar-profesional', [ 'profesional' => $profesional, 'profesiones' => $profesiones, 'servicios' => $servicios, 'serviciosSeleccionados' => $serviciosSeleccionados, ]); }); Route::get('/administrador/profesionales/{profesional}/asignaciones', function (Request $request, Profesional $profesional) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesional->load(['persona', 'profesion', 'profesiones', 'servicios']); $servicios = Servicio::query() ->where('estado', 'activo') ->orderBy('titulo') ->get(); $serviciosSeleccionados = $profesional->servicios() ->pluck('servicios.id') ->all(); return view('administrador.asignaciones-profesional', [ 'profesional' => $profesional, 'servicios' => $servicios, 'serviciosSeleccionados' => $serviciosSeleccionados, ]); }); Route::put('/administrador/profesionales/{profesional}/asignaciones', function (Request $request, Profesional $profesional) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $validated = $request->validate([ 'servicio_ids' => ['required', 'array', 'min:1'], 'servicio_ids.*' => [ 'required', Rule::exists('servicios', 'id')->where(fn ($q) => $q ->where('profesion_id', (int) $profesional->profesion_id) ->where('estado', 'activo')), ], ]); $profesional->profesiones()->sync([(int) $profesional->profesion_id]); $profesional->servicios()->sync($validated['servicio_ids']); $profesionActual = Profesion::query()->find((int) $profesional->profesion_id)?->titulo ?? 'Sin profesión'; $nombreCompleto = trim(($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? '')); $registrarLogSeguridad( $request, 'Edición datos profesional', 'Se actualizaron servicios del profesional ID ' . $profesional->id . ' (' . $nombreCompleto . ') en la profesión "' . $profesionActual . '". Servicios asignados: ' . implode(', ', $validated['servicio_ids']) . '.' ); return redirect('/administrador/profesionales') ->with('admin_action_success', 'Se actualizaron los servicios del profesional.'); }); Route::put('/administrador/profesionales/{profesional}', function (Request $request, Profesional $profesional) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $profesionBloqueadaId = (int) $profesional->profesion_id; $validated = $request->validate([ 'dni' => ['required', 'string', 'max:20', 'regex:/^[0-9A-Za-z]{7,20}$/', Rule::unique('personas', 'dni')->ignore((int) ($profesional->persona_id ?? 0))], 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'cuil' => ['required', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'fechanac' => ['required', 'date'], 'correo' => ['required', 'email', 'max:255'], 'matricula' => [ 'required', 'string', 'max:100', Rule::unique('profesionales', 'matricula') ->where(fn ($q) => $q->where('profesion_id', $profesionBloqueadaId)) ->ignore((int) $profesional->id), ], 'profesion_id' => ['required', Rule::in([$profesionBloqueadaId])], 'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], 'quitar_foto' => ['nullable', 'boolean'], 'servicio_ids' => ['required', 'array', 'min:1'], 'servicio_ids.*' => [ 'required', Rule::exists('servicios', 'id')->where(fn ($q) => $q ->where('profesion_id', $profesionBloqueadaId) ->where('estado', 'activo')), ], ]); $dniAnterior = (string) $profesional->dni; $cuilAnterior = (string) ($profesional->persona?->cuil ?? ''); $fechanacAnterior = (string) ($profesional->persona?->fechanac ?? ''); $nombreAnterior = (string) ($profesional->persona?->nombre ?? ''); $apellidoAnterior = (string) ($profesional->persona?->apellido ?? ''); $correoAnterior = $profesional->correo; $matriculaAnterior = (string) $profesional->matricula; $profesionAnteriorId = (int) $profesional->profesion_id; $usuarioCredencialAnterior = (string) ($profesional->credencialProfesional?->usuario ?? ''); $nuevoUsuarioCredencial = $validated['dni'] . '-' . $profesionBloqueadaId; if ( $nuevoUsuarioCredencial !== '' && $nuevoUsuarioCredencial !== $usuarioCredencialAnterior && CredencialProfesional::where('usuario', $nuevoUsuarioCredencial) ->where('id', '!=', (int) ($profesional->credencialprofesional_id ?? 0)) ->exists() ) { return back() ->withErrors(['profesion_id' => 'El usuario generado para esa combinación de DNI y profesión ya existe.']) ->withInput(); } if ($profesional->persona) { $profesional->persona->update([ 'dni' => $validated['dni'], 'nombre' => $validated['nombre'], 'apellido' => $validated['apellido'], 'cuil' => $validated['cuil'], 'fechanac' => $validated['fechanac'], ]); Profesional::query() ->where('persona_id', (int) $profesional->persona->id) ->update([ 'dni' => $validated['dni'], 'updated_at' => now(), ]); DB::table('personas_telefonos') ->where('persona_id', (int) $profesional->persona->id) ->update([ 'dni' => $validated['dni'], 'updated_at' => now(), ]); $fotoFile = $request->file('foto'); if ($fotoFile) { $directorio = public_path('images/profesionales'); File::ensureDirectoryExists($directorio); $extension = $fotoFile->getClientOriginalExtension(); $mimeType = $fotoFile->getClientMimeType(); $tamanioBytes = $fotoFile->getSize(); $nombreArchivo = uniqid('prof_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $foto = Foto::create([ 'extension' => $extension, 'nombre' => pathinfo($nombreArchivo, PATHINFO_FILENAME), 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'ruta' => 'images/profesionales/' . $nombreArchivo, ]); $profesional->persona->update([ 'foto_id' => $foto->id, ]); } elseif ((bool) ($validated['quitar_foto'] ?? false)) { $fotoDefault = Foto::firstOrCreate( ['ruta' => 'images/avatar_default.png'], [ 'extension' => 'png', 'nombre' => 'avatar_default', 'mime_type' => 'image/png', 'tamanio_bytes' => 136788, ] ); $profesional->persona->update([ 'foto_id' => $fotoDefault->id, ]); } } $profesional->update([ 'dni' => $validated['dni'], 'matricula' => $validated['matricula'], 'correo' => $validated['correo'], ]); $profesional->profesiones()->sync([$profesionBloqueadaId]); $profesional->servicios()->sync($validated['servicio_ids']); if ($profesional->credencialProfesional && $nuevoUsuarioCredencial !== '') { $profesional->credencialProfesional->update([ 'usuario' => $nuevoUsuarioCredencial, ]); } $nombreCompleto = trim(($profesional->persona?->nombre ?? '') . ' ' . ($profesional->persona?->apellido ?? '')); $registrarLogSeguridad( $request, 'Edición datos profesional', 'Se editó el profesional ID ' . $profesional->id . ' (' . $nombreCompleto . '). Nombre: "' . $nombreAnterior . '" -> "' . $validated['nombre'] . '", Apellido: "' . $apellidoAnterior . '" -> "' . $validated['apellido'] . '", CUIL: "' . $cuilAnterior . '" -> "' . $validated['cuil'] . '", Fecha Nac.: "' . $fechanacAnterior . '" -> "' . $validated['fechanac'] . '", Correo: "' . $correoAnterior . '" -> "' . $validated['correo'] . '", Matrícula: "' . $matriculaAnterior . '" -> "' . $validated['matricula'] . '", Profesión ID: "' . $profesionAnteriorId . '", Usuario credencial: "' . $usuarioCredencialAnterior . '" -> "' . $nuevoUsuarioCredencial . '".' ); if ($dniAnterior !== (string) $validated['dni']) { $personaResponsableId = Administrador::query() ->where('credencialprofesional_id', (int) $request->session()->get('personal_credencial_id', 0)) ->value('persona_id'); if ($personaResponsableId) { LogSeguridad::create([ 'descripcion' => 'Cambio de DNI del profesional ID ' . $profesional->id . ': "' . $dniAnterior . '" -> "' . $validated['dni'] . '".', 'fechahora' => now(), 'IPorigen' => (string) $request->ip(), 'rol' => 'ADMIN', 'persona_id' => (int) $personaResponsableId, 'accion_id' => 33, ]); } } return redirect('/administrador/profesionales') ->with('admin_action_success', 'Profesional actualizado correctamente.'); }); Route::post('/administrador/profesionales', function (Request $request) use ($registrarLogSeguridad, $validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $dni = trim((string) $request->input('dni', '')); $dniVerificado = trim((string) $request->input('dni_verificado', '')); // Verificar en DB si la persona ya existe (autoritativo, no se confía en el campo oculto) $personaExistente = Persona::where('dni', $dni)->first(); $reglas = [ 'dni' => ['required', 'string', 'max:20', 'regex:/^[0-9A-Za-z]{7,20}$/'], 'dni_verificado' => ['required', 'string', 'max:20'], 'correo' => ['required', 'email', 'max:255'], 'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], 'matricula' => ['required', 'string', 'max:100'], 'celular' => ['required', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'profesion_id' => ['required', 'exists:profesiones,id'], 'servicio_ids' => ['required', 'array', 'min:1'], 'servicio_ids.*' => [ 'required', Rule::exists('servicios', 'id')->where(fn ($q) => $q ->where('profesion_id', $request->input('profesion_id')) ->where('estado', 'activo')), ], 'contra' => ['required', 'string', 'min:6'], ]; if (!$personaExistente) { $reglas = array_merge($reglas, [ 'nombre' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'apellido' => ['required', 'string', 'max:100', 'regex:/^[\pL\s]+$/u'], 'cuil' => ['required', 'string', 'max:30', 'regex:/^[0-9]+$/'], 'fechanac' => ['required', 'date'], ]); } $validated = $request->validate($reglas); if ($dniVerificado === '' || $dniVerificado !== $dni) { return back() ->withErrors(['dni' => 'Debes verificar el DNI antes de guardar el profesional.']) ->withInput(); } // El usuario se auto-genera como DNI-CODIGO_PROFESION (id de profesion) $usuario = $validated['dni'] . '-' . $validated['profesion_id']; // Verificar que no exista ya un profesional con ese DNI y esa profesión if (Profesional::where('dni', $validated['dni'])->where('profesion_id', $validated['profesion_id'])->exists()) { return back() ->withErrors(['dni' => "Este profesional ya está registrado en esa profesión."]) ->withInput(); } // Verificar que el usuario auto-generado no esté en uso if (CredencialProfesional::where('usuario', $usuario)->exists()) { return back() ->withErrors(['dni' => "El usuario auto-generado «{$usuario}» ya existe. Verifique los datos."]) ->withInput(); } $fotoFile = $request->file('foto'); $profesionalCreado = null; DB::transaction(function () use ($validated, $fotoFile, $personaExistente, $usuario, &$profesionalCreado): void { if ($personaExistente) { $persona = $personaExistente; if ($fotoFile) { $directorio = public_path('images/profesionales'); File::ensureDirectoryExists($directorio); $extension = $fotoFile->getClientOriginalExtension(); $mimeType = $fotoFile->getClientMimeType(); $tamanioBytes = $fotoFile->getSize(); $nombreArchivo = uniqid('prof_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $foto = Foto::create([ 'extension' => $extension, 'nombre' => pathinfo($nombreArchivo, PATHINFO_FILENAME), 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'ruta' => 'images/profesionales/' . $nombreArchivo, ]); $persona->update([ 'foto_id' => $foto->id, ]); } } else { if ($fotoFile) { $directorio = public_path('images/profesionales'); File::ensureDirectoryExists($directorio); // Capturar metadatos ANTES de mover el archivo (move() invalida el objeto) $extension = $fotoFile->getClientOriginalExtension(); $mimeType = $fotoFile->getClientMimeType(); $tamanioBytes = $fotoFile->getSize(); $nombreArchivo = uniqid('prof_', true) . '.' . $extension; $fotoFile->move($directorio, $nombreArchivo); $foto = Foto::create([ 'extension' => $extension, 'nombre' => pathinfo($nombreArchivo, PATHINFO_FILENAME), 'mime_type' => $mimeType, 'tamanio_bytes' => $tamanioBytes, 'ruta' => 'images/profesionales/' . $nombreArchivo, ]); } else { $foto = Foto::firstOrCreate( ['ruta' => 'images/avatar_default.png'], [ 'extension' => 'png', 'nombre' => 'avatar_default', 'mime_type' => 'image/png', 'tamanio_bytes' => 136788, ] ); } $persona = Persona::create([ 'dni' => $validated['dni'], 'nombre' => $validated['nombre'], 'apellido' => $validated['apellido'], 'cuil' => $validated['cuil'], 'fechanac' => $validated['fechanac'], 'foto_id' => $foto->id, ]); } $telefonoExistente = $persona->telefonos()->first(); if ($telefonoExistente) { $telefonoExistente->update([ 'telefono' => $validated['celular'], ]); } else { $telefonoNuevo = Telefono::create([ 'telefono' => $validated['celular'], ]); DB::table('personas_telefonos')->insertOrIgnore([ 'dni' => (string) $persona->dni, 'persona_id' => (int) $persona->id, 'telefono_id' => (int) $telefonoNuevo->id, 'created_at' => now(), 'updated_at' => now(), ]); } $credencial = CredencialProfesional::create([ 'usuario' => $usuario, 'contra' => Hash::make($validated['contra']), 'rol' => 'PROFESIONAL', ]); $profesional = Profesional::create([ 'profesion_id' => (int) $validated['profesion_id'], 'matricula' => $validated['matricula'], 'correo' => $validated['correo'], 'dni' => $validated['dni'], 'persona_id' => $persona->id, 'credencialprofesional_id' => $credencial->id, 'baja_id' => 1, ]); Agenda::create([ 'profesional_id' => $profesional->id, 'estado' => 'activo', 'duracionturno' => 30, ]); $profesional->profesiones()->sync([(int) $validated['profesion_id']]); $profesional->servicios()->sync($validated['servicio_ids']); $profesionalCreado = $profesional; }); if ($profesionalCreado) { $nombreCompleto = trim(($profesionalCreado->persona?->nombre ?? '') . ' ' . ($profesionalCreado->persona?->apellido ?? '')); $registrarLogSeguridad( $request, 'Creación nuevo profesional', 'Se creó el profesional ID ' . $profesionalCreado->id . ' (' . $nombreCompleto . ').' ); } return redirect('/administrador/profesionales')->with('admin_action_success', 'Profesional creado correctamente.'); }); Route::get('/cliente/dashboard', function (Request $request) { if (!$request->session()->get('cliente_auth')) { return redirect('/login/cliente')->with('login_error', 'Debes iniciar sesión como cliente.'); } $clienteCorreoSesion = trim((string) $request->session()->get('cliente_correo', '')); $cliente = Cliente::query() ->with(['persona.telefonos', 'credencialCliente']) ->whereHas('credencialCliente', fn ($query) => $query->where('correo', $clienteCorreoSesion)) ->first(); $contenidoWeb = ContenidoWeb::latest('id')->first(); $quienesSomos = $contenidoWeb?->quienessomos; $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); $profesionales = Profesional::with(['persona.Foto', 'profesion', 'profesiones']) ->where('baja_id', 1) ->get() ->groupBy('persona_id') ->map(function ($profesionalesPorPersona) { $profesionalBase = $profesionalesPorPersona->first(); $profesionesCombinadas = $profesionalesPorPersona ->flatMap(function ($profesional) { return $profesional->profesiones ->push($profesional->profesion) ->filter(); }) ->unique('id') ->values(); $profesionalBase->setRelation('profesiones', $profesionesCombinadas); return $profesionalBase; }) ->values(); $servicios = Servicio::with('foto') ->where('estado', 'activo') ->where('visibleenweb', 'si') ->get(); $profesiones = Profesion::query() ->where('visible_en_formulario', true) ->orderBy('titulo') ->get(); $modalidades = Modalidad::query() ->orderBy('descripcion') ->get(); $mensajeBurbujaAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_BURBUJA) ->value('respuesta') ?? '¡Hola, soy Clara!, ¿te puedo ayudar en algo?'; $mensajeInicioPanelAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_PANEL_INICIO) ->value('respuesta') ?? 'Hola, soy Clara, la asistente virtual de Abogadas del Litoral. Escribí una palabra clave con el tema con el que necesites ayuda.'; $nombreAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_NOMBRE) ->value('respuesta') ?? 'Clara'; $chipsAsistente = FaqAsistente::query() ->where('activo', true) ->where('intencion', FAQ_INTENCION_CHIPS) ->orderBy('orden') ->orderBy('id') ->pluck('respuesta') ->flatMap(fn ($respuesta) => explode("\n", (string) $respuesta)) ->map(fn ($c) => trim($c)) ->filter(fn ($c) => $c !== '') ->unique() ->values() ->all(); $nombreClienteForm = trim((string) ($cliente?->persona?->nombre ?? $request->session()->get('cliente_nombre', ''))); $apellidoClienteForm = trim((string) ($cliente?->persona?->apellido ?? $request->session()->get('cliente_apellido', ''))); $correoClienteForm = trim((string) ($cliente?->correo ?? $cliente?->credencialCliente?->correo ?? $clienteCorreoSesion)); $celularClienteForm = trim((string) ($cliente?->persona?->telefonos?->first()?->telefono ?? $request->session()->get('cliente_celular', ''))); return view('cliente.dashboard', [ 'quienesSomos' => $quienesSomos, 'versionSitio' => $versionSitio, 'profesionales' => $profesionales, 'servicios' => $servicios, 'profesiones' => $profesiones, 'modalidades' => $modalidades, 'mensajeBurbujaAsistente' => $mensajeBurbujaAsistente, 'mensajeInicioPanelAsistente' => $mensajeInicioPanelAsistente, 'nombreAsistente' => $nombreAsistente, 'chipsAsistente' => $chipsAsistente, 'nombreClienteForm' => $nombreClienteForm, 'apellidoClienteForm' => $apellidoClienteForm, 'correoClienteForm' => $correoClienteForm, 'celularClienteForm' => $celularClienteForm, ]); }); Route::get('/cliente/mis-turnos', function (Request $request) { if (!$request->session()->get('cliente_auth')) { return redirect('/login/cliente')->with('login_error', 'Debes iniciar sesión como cliente.'); } $clienteIdSesion = (int) $request->session()->get('cliente_id', 0); $clienteCorreoSesion = trim((string) $request->session()->get('cliente_correo', '')); $periodo = trim((string) $request->query('periodo', 'todos')); $estadoId = (int) $request->query('estado_id', 0); $turnosQuery = Turno::query() ->with([ 'profesional.persona', 'profesional.profesion', 'servicio', 'modalidad', 'estadoTurno', ]) ->where(function ($query) use ($clienteIdSesion, $clienteCorreoSesion): void { if ($clienteIdSesion > 0) { $query->where('cliente_id', $clienteIdSesion); } if ($clienteCorreoSesion !== '') { if ($clienteIdSesion > 0) { $query->orWhere('correo', $clienteCorreoSesion); } else { $query->where('correo', $clienteCorreoSesion); } } }); if ($periodo === 'proximos') { $turnosQuery->where('inicio', '>=', now()); } elseif ($periodo === 'pasados') { $turnosQuery->where('inicio', '<', now()); } if ($estadoId > 0) { $turnosQuery->where('estadoturno_id', $estadoId); } $turnos = $turnosQuery ->orderByDesc('inicio') ->paginate(5) ->withQueryString(); $estadosTurno = EstadoTurno::query() ->whereNotIn(DB::raw('LOWER(TRIM(descripcion))'), ['cliente ausente', 'cliente presente']) ->orderBy('descripcion') ->get(['id', 'descripcion']); $contenidoWeb = ContenidoWeb::latest('id')->first(); $versionSitio = trim((string) ($contenidoWeb?->version ?? '1.0.0')); return view('cliente.mis-turnos', [ 'turnos' => $turnos, 'estadosTurno' => $estadosTurno, 'filtroPeriodo' => $periodo, 'filtroEstadoId' => $estadoId, 'versionSitio' => $versionSitio, ]); }); Route::get('/cliente/ayuda', function (Request $request) { if (!$request->session()->get('cliente_auth')) { return redirect('/login/cliente')->with('login_error', 'Debes iniciar sesión como cliente.'); } return view('cliente.ayuda'); }); Route::post('/cliente/turnos/{turno}/cancelar', function (Request $request, Turno $turno) { if (!$request->session()->get('cliente_auth')) { return redirect('/login/cliente')->with('login_error', 'Debes iniciar sesión como cliente.'); } $clienteIdSesion = (int) $request->session()->get('cliente_id', 0); $clienteCorreoSesion = trim((string) $request->session()->get('cliente_correo', '')); $esTurnoDelCliente = ($clienteIdSesion > 0 && (int) $turno->cliente_id === $clienteIdSesion) || ($clienteCorreoSesion !== '' && strcasecmp((string) ($turno->correo ?? ''), $clienteCorreoSesion) === 0); if (!$esTurnoDelCliente) { return back()->with('turno_error', 'No podés cancelar un turno que no te pertenece.'); } $estadoCanceladoId = (int) (EstadoTurno::query() ->whereRaw('LOWER(TRIM(descripcion)) = ?', ['cancelado']) ->value('id') ?? 0); if ($estadoCanceladoId <= 0) { return back()->with('turno_error', 'No se encontró el estado Cancelado.'); } if ((int) $turno->estadoturno_id === $estadoCanceladoId) { return back()->with('turno_error', 'El turno seleccionado ya está cancelado.'); } $turno->update([ 'estadoturno_id' => $estadoCanceladoId, ]); $turno->loadMissing(['profesional.persona', 'servicio', 'modalidad']); $correoProfesional = trim((string) ($turno->profesional?->correo ?? '')); if ($correoProfesional !== '') { $nombreProfesional = trim((string) (($turno->profesional?->persona?->nombre ?? '') . ' ' . ($turno->profesional?->persona?->apellido ?? ''))); $nombreCliente = trim((string) ($turno->nombrecompleto ?? 'Cliente')); $fechaTurno = $turno->inicio ? $turno->inicio->format('d/m/Y') : '-'; $horaTurno = $turno->inicio ? $turno->inicio->format('H:i') : '-'; $servicioTurno = trim((string) ($turno->servicio?->titulo ?? 'Servicio')); $modalidadTurno = trim((string) ($turno->modalidad?->descripcion ?? 'Modalidad')); $asunto = 'Cancelación de turno por cliente'; $cuerpo = "Hola " . ($nombreProfesional !== '' ? $nombreProfesional : 'profesional') . ",\n\n" . "El cliente {$nombreCliente} canceló su turno.\n" . "- Fecha: {$fechaTurno}\n" . "- Hora: {$horaTurno}\n" . "- Servicio: {$servicioTurno}\n" . "- Modalidad: {$modalidadTurno}\n"; try { Mail::raw($cuerpo, function ($message) use ($correoProfesional, $asunto): void { $message->to($correoProfesional)->subject($asunto); }); } catch (\Throwable $e) { logger()->warning('No se pudo enviar correo al profesional por cancelación de cliente.', [ 'turno_id' => (int) $turno->id, 'correo_profesional' => $correoProfesional, 'error' => $e->getMessage(), ]); } } return back()->with('turno_success', 'El turno se canceló correctamente y se notificó al profesional.'); }); Route::get('/logout', function (Request $request) use ($registrarLogSeguridadDirecto) { $sesionPersonalActiva = (bool) $request->session()->get('personal_auth', false); $eraCliente = (bool) $request->session()->get('cliente_auth', false) && !$sesionPersonalActiva; if ($eraCliente) { $clienteId = (int) $request->session()->get('cliente_id', 0); $personaId = Cliente::query()->where('id', $clienteId)->value('persona_id'); $registrarLogSeguridadDirecto( $personaId ? (int) $personaId : null, 'CLIENTE', (string) $request->ip(), 'Cerró sesión', 'El cliente ID ' . $clienteId . ' cerró sesión.' ); } else { $credencialId = (int) $request->session()->get('personal_credencial_id', 0); $rolSesion = strtoupper((string) $request->session()->get('personal_rol', 'PROFESIONAL')); if ($rolSesion === 'ADMIN') { $admin = Administrador::query()->where('credencialprofesional_id', $credencialId)->first(); $registrarLogSeguridadDirecto( $admin?->persona_id ? (int) $admin->persona_id : null, 'ADMIN', (string) $request->ip(), 'Cerró sesión', 'La administradora ID ' . (int) ($admin?->id ?? 0) . ' cerró sesión.' ); } else { $profesional = Profesional::query()->where('credencialprofesional_id', $credencialId)->first(); $registrarLogSeguridadDirecto( $profesional?->persona_id ? (int) $profesional->persona_id : null, 'PROFESIONAL', (string) $request->ip(), 'Cerró sesión', 'El profesional ID ' . (int) ($profesional?->id ?? 0) . ' cerró sesión.' ); } } $request->session()->invalidate(); $request->session()->regenerateToken(); $redirectPath = $eraCliente ? '/login/cliente' : '/login/personal'; return redirect($redirectPath)->with('logout_success', 'Sesión cerrada correctamente.'); }); Route::get('/administrador/ayuda', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } return view('administrador.ayuda'); }); Route::get('/administrador/backups', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $disk = \Illuminate\Support\Facades\Storage::disk('local'); $backupFolder = trim((string) config('backup.backup.name', 'Laravel'), '/'); $archivos = collect($disk->files($backupFolder)) ->filter(fn (string $file): bool => str_ends_with(strtolower($file), '.zip')) ->sortByDesc(fn (string $file): int => $disk->lastModified($file)) ->map(fn (string $file): array => [ 'nombre' => basename($file), 'ruta' => $file, 'tamanio' => round($disk->size($file) / 1024, 2), 'fecha' => \Illuminate\Support\Carbon::createFromTimestampUTC($disk->lastModified($file)) ->setTimezone((string) config('app.timezone', 'America/Argentina/Buenos_Aires')) ->format('d/m/Y H:i:s'), ]) ->values(); return view('administrador.backups', compact('archivos')); }); Route::get('/administrador/backups/descargar', function (Request $request) use ($validarSesionAdmin) { if ($respuesta = $validarSesionAdmin($request)) { return $respuesta; } $archivo = (string) $request->query('archivo', ''); if ($archivo === '') { abort(400, 'Archivo no especificado.'); } $disk = \Illuminate\Support\Facades\Storage::disk('local'); // Validar que el archivo está dentro de la carpeta de backups (evitar path traversal) $backupFolder = trim((string) config('backup.backup.name', 'Laravel'), '/'); $archivoNormalizado = ltrim(str_replace('\\', '/', $archivo), '/'); if (!str_starts_with($archivoNormalizado, $backupFolder . '/') || !str_ends_with(strtolower($archivoNormalizado), '.zip')) { abort(403, 'Acceso no permitido.'); } if (!$disk->exists($archivoNormalizado)) { abort(404, 'Archivo no encontrado.'); } return response()->streamDownload(function () use ($disk, $archivoNormalizado): void { echo $disk->get($archivoNormalizado); }, basename($archivoNormalizado), [ 'Content-Type' => 'application/zip', ]); });