Files
sistema-abogadas-litoral/routes/web.php
T

6281 lines
246 KiB
PHP

<?php
use App\Http\Controllers\AuthController;
use App\Models\ContenidoWeb;
use App\Models\Baja;
use App\Models\Agenda;
use App\Models\Dia;
use App\Models\DiaDeAtencion;
use App\Models\DiaPreferencia;
use App\Models\EstadoTurno;
use App\Models\Feriado;
use App\Models\Formulario;
use App\Models\HorarioDeAtencion;
use App\Models\HorarioPreferencia;
use App\Models\HorarioReceso;
use App\Models\Modalidad;
use App\Models\ModoVacaciones;
use App\Models\CredencialProfesional;
use App\Models\Foto;
use App\Models\AccionLog;
use App\Models\Administrador;
use App\Models\CredencialCliente;
use App\Models\LogSeguridad;
use App\Models\Cliente;
use App\Models\DocumentacionCliente;
use App\Models\Bug;
use App\Models\Error;
use App\Models\FotoBug;
use App\Models\Persona;
use App\Models\Profesion;
use App\Models\Profesional;
use App\Models\Turno;
use App\Models\Servicio;
use App\Models\Telefono;
use App\Models\FaqAsistente;
use App\Models\AsistenteSinRespuesta;
use App\Models\Notificacion;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Database\QueryException;
if (!defined('FAQ_INTENCION_BURBUJA')) {
define('FAQ_INTENCION_BURBUJA', 'ui_burbuja');
}
if (!defined('FAQ_INTENCION_PANEL_INICIO')) {
define('FAQ_INTENCION_PANEL_INICIO', 'ui_panel_inicio');
}
if (!defined('FAQ_INTENCION_ERROR')) {
define('FAQ_INTENCION_ERROR', 'ui_error');
}
if (!defined('FAQ_INTENCION_NOMBRE')) {
define('FAQ_INTENCION_NOMBRE', 'ui_nombre');
}
if (!defined('FAQ_INTENCION_CHIPS')) {
define('FAQ_INTENCION_CHIPS', 'ui_chips');
}
$obtenerNombrePersonaLog = function (?int $personaResponsableId): ?string {
if (!$personaResponsableId) {
return null;
}
$persona = Persona::query()->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',
]);
});