From 9474236226682194be6687746554dd72296159a0 Mon Sep 17 00:00:00 2001 From: Lucho Date: Wed, 24 Jun 2026 16:29:04 -0300 Subject: [PATCH] =?UTF-8?q?Rutas=20del=20sistema=20y=20archivos=20de=20con?= =?UTF-8?q?figuraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/app.php | 8 +- config/backup.php | 344 +++ config/database.php | 9 +- routes/api.php | 6 +- routes/console.php | 225 ++ routes/web.php | 6277 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 6856 insertions(+), 13 deletions(-) create mode 100644 config/backup.php diff --git a/config/app.php b/config/app.php index 423eed5..34db982 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => 'America/Argentina/Buenos_Aires', /* |-------------------------------------------------------------------------- @@ -78,11 +78,11 @@ return [ | */ - 'locale' => env('APP_LOCALE', 'en'), + 'locale' => env('APP_LOCALE', 'es'), - 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'), - 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + 'faker_locale' => env('APP_FAKER_LOCALE', 'es_AR'), /* |-------------------------------------------------------------------------- diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..efb566e --- /dev/null +++ b/config/backup.php @@ -0,0 +1,344 @@ + [ + /* + * The name of this application. You can use this name to monitor + * the backups. + */ + 'name' => env('APP_NAME', 'AbogadasDelLitoral'), + + 'source' => [ + 'files' => [ + /* + * The list of directories and files that will be included in the backup. + */ + 'include' => array_values(array_filter([ + storage_path('app/public/fotos'), + storage_path('app/private/documentacion-clientes'), + public_path('images/profesionales'), + public_path('images/servicios'), + public_path('images/bugs'), + public_path('uploads/documentacion-clientes'), + filter_var(env('BACKUP_INCLUDE_ENV', false), FILTER_VALIDATE_BOOLEAN) ? base_path('.env') : null, + ], static fn (mixed $path): bool => is_string($path) && (is_dir($path) || is_file($path)))), + + /* + * These directories and files will be excluded from the backup. + * + * Directories used by the backup process will automatically be excluded. + */ + 'exclude' => [], + + /* + * Determines if symlinks should be followed. + */ + 'follow_links' => false, + + /* + * Determines if it should avoid unreadable folders. + */ + 'ignore_unreadable_directories' => false, + + /* + * This path is used to make directories in resulting zip-file relative + * Set to `null` to include complete absolute path + * Example: base_path() + */ + 'relative_path' => base_path(), + ], + + /* + * The names of the connections to the databases that should be backed up + * MySQL, PostgreSQL, SQLite and Mongo databases are supported. + * + * The content of the database dump may be customized for each connection + * by adding a 'dump' key to the connection settings in config/database.php. + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'exclude_tables' => [ + * 'table_to_exclude_from_backup', + * 'another_table_to_exclude' + * ] + * ], + * ], + * + * If you are using only InnoDB tables on a MySQL server, you can + * also supply the useSingleTransaction option to avoid table locking. + * + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'useSingleTransaction' => true, + * ], + * ], + * + * For a complete list of available customization options, see https://github.com/spatie/db-dumper + */ + 'databases' => [ + env('DB_CONNECTION', 'mysql'), + ], + ], + + /* + * The database dump can be compressed to decrease disk space usage. + * + * Out of the box Laravel-backup supplies + * Spatie\DbDumper\Compressors\GzipCompressor::class. + * + * You can also create custom compressor. More info on that here: + * https://github.com/spatie/db-dumper#using-compression + * + * If you do not want any compressor at all, set it to null. + */ + 'database_dump_compressor' => null, + + /* + * If specified, the database dumped file name will contain a timestamp (e.g.: 'Y-m-d-H-i-s'). + */ + 'database_dump_file_timestamp_format' => null, + + /* + * The base of the dump filename, either 'database' or 'connection' + * + * If 'database' (default), the dumped filename will contain the database name. + * If 'connection', the dumped filename will contain the connection name. + */ + 'database_dump_filename_base' => 'database', + + /* + * The file extension used for the database dump files. + * + * If not specified, the file extension will be .archive for MongoDB and .sql for all other databases + * The file extension should be specified without a leading . + */ + 'database_dump_file_extension' => '', + + 'destination' => [ + /* + * The compression algorithm to be used for creating the zip archive. + * + * If backing up only database, you may choose gzip compression for db dump and no compression at zip. + * + * Some common algorithms are listed below: + * ZipArchive::CM_STORE (no compression at all; set 0 as compression level) + * ZipArchive::CM_DEFAULT + * ZipArchive::CM_DEFLATE + * ZipArchive::CM_BZIP2 + * ZipArchive::CM_XZ + * + * For more check https://www.php.net/manual/zip.constants.php and confirm it's supported by your system. + */ + 'compression_method' => ZipArchive::CM_DEFAULT, + + /* + * The compression level corresponding to the used algorithm; an integer between 0 and 9. + * + * Check supported levels for the chosen algorithm, usually 1 means the fastest and weakest compression, + * while 9 the slowest and strongest one. + * + * Setting of 0 for some algorithms may switch to the strongest compression. + */ + 'compression_level' => 9, + + /* + * The filename prefix used for the backup zip file. + */ + 'filename_prefix' => '', + + /* + * The disk names on which the backups will be stored. + */ + 'disks' => [ + ...array_values(array_filter(array_map('trim', explode(',', env('BACKUP_DESTINATION_DISK', 'local'))))), + ], + ], + + /* + * The directory where the temporary files will be stored. + */ + 'temporary_directory' => storage_path('app/backup-temp'), + + /* + * The password to be used for archive encryption. + * Set to `null` to disable encryption. + */ + 'password' => env('BACKUP_ARCHIVE_PASSWORD') ?: null, + + /* + * The encryption algorithm to be used for archive encryption. + * You can set it to `null` or `false` to disable encryption. + * + * When set to 'default', we'll use ZipArchive::EM_AES_256 if it is + * available on your system. + */ + 'encryption' => env('BACKUP_ARCHIVE_PASSWORD') ? 'default' : false, + + /* + * The number of attempts, in case the backup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new backup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + * + * You can also use your own notification classes, just make sure the class is named after one of + * the `Spatie\Backup\Notifications\Notifications` classes. + */ + 'notifications' => [ + 'notifications' => [ + \Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification::class => ['mail'], + \Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification::class => ['mail'], + \Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification::class => ['mail'], + \Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification::class => [], + \Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification::class => [], + \Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification::class => [], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => \Spatie\Backup\Notifications\Notifiable::class, + + 'mail' => [ + 'to' => env('BACKUP_MAIL_TO', env('MAIL_FROM_ADDRESS', 'admin@example.com')), + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => '', + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + + 'discord' => [ + 'webhook_url' => '', + + /* + * If this is an empty string, the name field on the webhook will be used. + */ + 'username' => '', + + /* + * If this is an empty string, the avatar on the webhook will be used. + */ + 'avatar_url' => '', + ], + ], + + /* + * Here you can specify which backups should be monitored. + * If a backup does not meet the specified requirements the + * UnHealthyBackupWasFound event will be fired. + */ + 'monitor_backups' => [ + [ + 'name' => env('APP_NAME', 'laravel-backup'), + 'disks' => array_values(array_filter(array_map('trim', explode(',', env('BACKUP_DESTINATION_DISK', 'local'))))), + 'health_checks' => [ + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => (int) env('BACKUP_MAX_AGE_DAYS', 7), + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => (int) env('BACKUP_MAX_STORAGE_MB', 5000), + ], + ], + + /* + [ + 'name' => 'name of the second app', + 'disks' => ['local', 's3'], + 'health_checks' => [ + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1, + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000, + ], + ], + */ + ], + + 'cleanup' => [ + /* + * The strategy that will be used to cleanup old backups. The default strategy + * will keep all backups for a certain amount of days. After that period only + * a daily backup will be kept. After that period only weekly backups will + * be kept and so on. + * + * No matter how you configure it the default strategy will never + * delete the newest backup. + */ + 'strategy' => \Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy::class, + + 'default_strategy' => [ + /* + * The number of days for which backups must be kept. + */ + 'keep_all_backups_for_days' => (int) env('BACKUP_KEEP_ALL_DAYS', 7), + + /* + * After the "keep_all_backups_for_days" period is over, the most recent backup + * of that day will be kept. Older backups within the same day will be removed. + * If you create backups only once a day, no backups will be removed yet. + */ + 'keep_daily_backups_for_days' => (int) env('BACKUP_KEEP_DAILY_DAYS', 16), + + /* + * After the "keep_daily_backups_for_days" period is over, the most recent backup + * of that week will be kept. Older backups within the same week will be removed. + * If you create backups only once a week, no backups will be removed yet. + */ + 'keep_weekly_backups_for_weeks' => (int) env('BACKUP_KEEP_WEEKLY_WEEKS', 8), + + /* + * After the "keep_weekly_backups_for_weeks" period is over, the most recent backup + * of that month will be kept. Older backups within the same month will be removed. + */ + 'keep_monthly_backups_for_months' => (int) env('BACKUP_KEEP_MONTHLY_MONTHS', 4), + + /* + * After the "keep_monthly_backups_for_months" period is over, the most recent backup + * of that year will be kept. Older backups within the same year will be removed. + */ + 'keep_yearly_backups_for_years' => (int) env('BACKUP_KEEP_YEARLY_YEARS', 2), + + /* + * After cleaning up the backups remove the oldest backup until + * this amount of megabytes has been reached. + * Set null for unlimited size. + */ + 'delete_oldest_backups_when_using_more_megabytes_than' => (int) env('BACKUP_MAX_STORAGE_LIMIT_MB', 5000), + ], + + /* + * The number of attempts, in case the cleanup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new cleanup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + +]; diff --git a/config/database.php b/config/database.php index df933e7..38da55d 100644 --- a/config/database.php +++ b/config/database.php @@ -16,7 +16,7 @@ return [ | */ - 'default' => env('DB_CONNECTION', 'sqlite'), + 'default' => env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- @@ -60,7 +60,13 @@ return [ 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci', ]) : [], + 'dump' => [ + 'dump_binary_path' => env('DB_DUMP_BINARY_PATH', PHP_OS_FAMILY === 'Windows' ? 'C:/xampp/mysql/bin/' : '/usr/bin/'), + 'use_single_transaction' => true, + 'timeout' => 60 * 5, // 5 minutos de tiempo límite + ], ], 'mariadb' => [ @@ -80,6 +86,7 @@ return [ 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci', ]) : [], ], diff --git a/routes/api.php b/routes/api.php index 042dd12..20103fe 100644 --- a/routes/api.php +++ b/routes/api.php @@ -39,9 +39,9 @@ use App\Http\Controllers\UbicacionController; use App\Http\Controllers\UserController; Route::middleware('api')->group(function () { - Route::post('auth/login/cliente', [AuthController::class, 'loginCliente']); - Route::post('auth/login/personal', [AuthController::class, 'loginPersonal']); - Route::post('auth/login', [AuthController::class, 'login']); + Route::post('auth/login/cliente', [AuthController::class, 'loginCliente'])->middleware('throttle:login-cliente-api'); + Route::post('auth/login/personal', [AuthController::class, 'loginPersonal'])->middleware('throttle:login-personal-api'); + Route::post('auth/login', [AuthController::class, 'login'])->middleware('throttle:login-api-general'); Route::post('auth/logout', [AuthController::class, 'logout']); // Rutas API Resource estándar diff --git a/routes/console.php b/routes/console.php index 3c9adf1..064498a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,233 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('logs:purge-old-security', function () { + $fechaLimite = now()->subMonths(2); + + $eliminados = LogSeguridad::query() + ->where('fechahora', '<', $fechaLimite) + ->delete(); + + $this->info('Logs eliminados: ' . $eliminados); +})->purpose('Elimina logs de seguridad con más de 2 meses de antigüedad'); + +Artisan::command('seed:bulk {--modo=masivo} {--clientes=} {--profesionales=} {--servicios=} {--profesiones=} {--fresh}', function () { + $presets = [ + 'rapido' => ['clientes' => 100, 'profesionales' => 20, 'servicios' => 10, 'profesiones' => 2], + 'medio' => ['clientes' => 500, 'profesionales' => 50, 'servicios' => 20, 'profesiones' => 3], + 'masivo' => ['clientes' => 1000, 'profesionales' => 100, 'servicios' => 30, 'profesiones' => 4], + ]; + + $modo = strtolower((string) $this->option('modo')); + + if (!array_key_exists($modo, $presets)) { + $this->error('Modo invalido. Usa: rapido, medio o masivo.'); + + return 1; + } + + $clientes = max(0, (int) ($this->option('clientes') ?? $presets[$modo]['clientes'])); + $profesionales = max(0, (int) ($this->option('profesionales') ?? $presets[$modo]['profesionales'])); + $servicios = max(0, (int) ($this->option('servicios') ?? $presets[$modo]['servicios'])); + $profesiones = max(0, (int) ($this->option('profesiones') ?? $presets[$modo]['profesiones'])); + + config([ + 'bulk_seed.enabled' => false, + 'bulk_seed.clientes' => $clientes, + 'bulk_seed.profesionales' => $profesionales, + 'bulk_seed.servicios' => $servicios, + 'bulk_seed.profesiones' => $profesiones, + ]); + + if ((bool) $this->option('fresh')) { + $this->info('Ejecutando migrate:fresh --seed...'); + $exitMigrate = Artisan::call('migrate:fresh', ['--seed' => true]); + $this->line(Artisan::output()); + + if ($exitMigrate !== 0) { + $this->error('Fallo migrate:fresh --seed'); + + return $exitMigrate; + } + } + + $this->info("Cargando datos masivos (modo={$modo})..."); + $exitBulk = Artisan::call('db:seed', ['--class' => BulkDataSeeder::class]); + $this->line(Artisan::output()); + + if ($exitBulk !== 0) { + $this->error('Fallo la ejecucion de BulkDataSeeder.'); + + return $exitBulk; + } + + $this->info("Datos creados: {$profesiones} profesiones, {$servicios} servicios, {$clientes} clientes y {$profesionales} profesionales."); + + return 0; +})->purpose('Carga datos de prueba masivos por modo o por cantidades personalizadas'); + +Artisan::command('formularios:sync-rejected-status', function () { + $formulariosPendientes = Formulario::query() + ->where('estado', 'Pendiente') + ->get(['id', 'profesion_id']); + + $actualizados = 0; + + foreach ($formulariosPendientes as $formulario) { + $formularioId = (int) $formulario->id; + $profesionFormularioId = (int) $formulario->profesion_id; + + $asignadosActivos = DB::table('profesionales_formularios as pf') + ->join('profesionales as p', 'p.id', '=', 'pf.profesional_id') + ->where('pf.formulario_id', $formularioId) + ->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); + }); + }) + ->count(); + + if ($asignadosActivos === 0) { + continue; + } + + $hayActivosSinRechazar = DB::table('profesionales_formularios as pf') + ->join('profesionales as p', 'p.id', '=', 'pf.profesional_id') + ->where('pf.formulario_id', $formularioId) + ->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); + }); + }) + ->whereRaw("LOWER(TRIM(pf.estado)) <> 'rechazado'") + ->exists(); + + if (!$hayActivosSinRechazar) { + Formulario::query() + ->where('id', $formularioId) + ->update(['estado' => 'Rechazado por todos']); + + $actualizados++; + } + } + + $this->info('Formularios actualizados: ' . $actualizados); +})->purpose('Sincroniza formularios pendientes a Rechazado por todos cuando todos los profesionales activos rechazaron'); + +Artisan::command('backup:daily-email {--keep=4}', function () { + $includeEnv = (bool) filter_var((string) env('BACKUP_INCLUDE_ENV', false), FILTER_VALIDATE_BOOLEAN); + $archivePassword = trim((string) env('BACKUP_ARCHIVE_PASSWORD', '')); + + if ($includeEnv && $archivePassword === '') { + $this->error('BACKUP_INCLUDE_ENV=true requiere BACKUP_ARCHIVE_PASSWORD configurado para cifrar el ZIP.'); + + return 1; + } + + $this->info('Iniciando backup semanal de base de datos y archivos...'); + + $backupExitCode = Artisan::call('backup:run'); + $this->line(Artisan::output()); + + if ($backupExitCode !== 0) { + $this->error('No se pudo generar el backup.'); + + return 1; + } + + $disk = Storage::disk('local'); + $backupFolder = trim((string) config('backup.backup.name', 'Laravel'), '/'); + + $files = collect($disk->files($backupFolder)) + ->filter(fn (string $file): bool => str_ends_with(strtolower($file), '.zip')) + ->sortByDesc(fn (string $file): int => $disk->lastModified($file)) + ->values(); + + if ($files->isEmpty()) { + $this->error('No se encontró el archivo ZIP del backup para enviar por email.'); + + return 1; + } + + $latestBackup = (string) $files->first(); + + $adminRecipients = Administrador::query() + ->select('correo') + ->whereNotNull('correo') + ->pluck('correo') + ->map(fn ($correo): string => trim((string) $correo)) + ->filter(fn (string $correo): bool => $correo !== '' && filter_var($correo, FILTER_VALIDATE_EMAIL) !== false) + ->unique() + ->values() + ->all(); + + $fallbackRecipient = (string) env('BACKUP_MAIL_TO', (string) config('backup.notifications.mail.to')); + $recipients = !empty($adminRecipients) + ? $adminRecipients + : array_values(array_filter([(string) $fallbackRecipient])); + + if (empty($recipients)) { + $this->error('No hay correos de administradores ni BACKUP_MAIL_TO configurado para enviar el backup.'); + + return 1; + } + + try { + Mail::raw('Se adjunta el backup semanal de base de datos y archivos.', function ($message) use ($disk, $latestBackup, $recipients): void { + $message->to($recipients) + ->subject('Backup semanal - ' . now()->format('d/m/Y')) + ->attachData( + $disk->get($latestBackup), + basename($latestBackup), + ['mime' => 'application/zip'] + ); + }); + } catch (\Throwable $e) { + $this->error('El backup se generó, pero falló el envío de email: ' . $e->getMessage()); + + return 1; + } + + $keep = max(1, (int) $this->option('keep')); + + $files->slice($keep)->each(function (string $file) use ($disk): void { + $disk->delete($file); + }); + + $deletedCount = max(0, $files->count() - $keep); + + $this->info('Backup enviado a: ' . implode(', ', $recipients)); + $this->info('Backups eliminados por retención: ' . $deletedCount); + $this->info('Backups conservados: ' . $keep); + + return 0; +})->purpose('Genera backup semanal de BD y archivos, lo envía por mail y conserva solo los últimos N backups'); + +Schedule::command('logs:purge-old-security')->daily(); +Schedule::command('backup:daily-email --keep=4')->dailyAt('16:00'); //ejecuta backup:run +Schedule::command('backup:clean')->dailyAt('16:20'); +Schedule::command('backup:monitor')->dailyAt('16:30'); diff --git a/routes/web.php b/routes/web.php index 1f8aed5..ec665d1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,6280 @@ 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 () { - return view('welcome'); + $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::view('/login/cliente', 'auth.login-cliente'); -Route::view('/login/personal', 'auth.login-personal'); -Route::post('/login/cliente', [AuthController::class, 'loginClienteWeb']); -Route::post('/login/personal', [AuthController::class, 'loginPersonalWeb']); +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', + ]); +});