# Plan Técnico: Módulo de Agendamiento de Citas - Lash Vanshy ## 1. Estructura de Base de Datos ### 1.1 Tabla: citas ```php // database/migrations/2024_01_01_000008_create_citas_table.php Schema::create('citas', function (Blueprint $table) { $table->id(); $table->foreignId('mensaje_id')->nullable()->constrained('mensajes')->onDelete('set null'); $table->string('cliente_nombre'); $table->string('cliente_email'); $table->string('cliente_telefono', 50)->nullable(); $table->string('servicio')->default('Lash Extensions'); $table->date('fecha'); $table->time('hora_inicio'); $table->time('hora_fin'); $table->enum('estado', ['pendiente', 'confirmada', 'completada', 'cancelada'])->default('pendiente'); $table->text('notas')->nullable(); $table->timestamps(); }); ``` ### 1.2 Tabla: horarios_bloqueados ```php // database/migrations/2024_01_01_000009_create_horarios_bloqueados_table.php Schema::create('horarios_bloqueados', function (Blueprint $table) { $table->id(); $table->date('fecha'); $table->time('hora_inicio'); $table->time('hora_fin'); $table->string('motivo')->nullable(); $table->timestamps(); }); ``` ## 2. Modelos ### 2.1 App/Models/Cita.php ```php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Cita extends Model { protected $fillable = [ 'mensaje_id', 'cliente_nombre', 'cliente_email', 'cliente_telefono', 'servicio', 'fecha', 'hora_inicio', 'hora_fin', 'estado', 'notas', ]; protected $casts = [ 'fecha' => 'date', 'hora_inicio' => 'datetime:H:i', 'hora_fin' => 'datetime:H:i', ]; public function mensaje(): BelongsTo { return $this->belongsTo(Mensaje::class); } public function scopePendientes($query) { /* ... */ } public function scopeConfirmadas($query) { /* ... */ } public function scopePorFecha($query, $fecha) { /* ... */ } } ``` ### 2.2 App/Models/HorarioBloqueado.php ```php namespace App\Models; use Illuminate\Database\Eloquent\Model; class HorarioBloqueado extends Model { protected $fillable = ['fecha', 'hora_inicio', 'hora_fin', 'motivo']; protected $casts = ['fecha' => 'date']; } ``` ## 3. Backend - Controladores y API ### 3.1 App/Http/Controllers/Admin/CitaController.php ```php namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Citas; use App\Models\HorarioBloqueado; use Illuminate\Http\Request; use Carbon\Carbon; class CitaController extends Controller { // --- CRUD BÁSICO --- public function index(Request $request) { $citas = Cita::with('mensaje') ->when($request->fecha, fn($q) => $q->whereDate('fecha', $request->fecha)) ->when($request->estado, fn($q) => $q->where('estado', $request->estado)) ->orderBy('fecha')->orderBy('hora_inicio') ->paginate(20); return view('admin.citas.index', compact('citas')); } public function create(Request $request) { $mensajeId = $request->mensaje_id; $mensaje = $mensajeId ? Mensaje::find($mensajeId) : null; $fechasBloqueadas = HorarioBloqueado::pluck('fecha')->toArray(); return view('admin.citas.create', compact('mensaje', 'fechasBloqueadas')); } public function store(Request $request) { $validated = $request->validate([ 'cliente_nombre' => 'required', 'cliente_email' => 'required|email', 'cliente_telefono' => 'nullable', 'fecha' => 'required|date|after_or_equal:today', 'hora_inicio' => 'required', 'notas' => 'nullable', ]); // Calcular hora fin (60 minutos) $horaFin = Carbon::parse($validated['hora_inicio'])->addMinutes(60)->format('H:i:s'); // Verificar disponibilidad if (!self::verificarDisponibilidad($validated['fecha'], $validated['hora_inicio'], $horaFin)) { return back()->with('error', 'El horario no está disponible.'); } Cita::create([ ...$validated, 'mensaje_id' => $request->mensaje_id, 'hora_fin' => $horaFin, 'servicio' => 'Lash Extensions', ]); return redirect()->route('admin.citas.index')->with('success', 'Cita agendada correctamente.'); } public function edit(Cita $cita) { /* ... */ } public function update(Request $request, Cita $cita) { /* ... */ } public function destroy(Cita $cita) { $cita->delete(); return back()->with('success', 'Cita eliminada.'); } // --- API DE DISPONIBILIDAD --- public function getHorariosDisponibles(Request $request) { $fecha = $request->fecha; $citas = Cita::whereDate('fecha', $fecha)->where('estado', '!=', 'cancelada')->get(); $bloqueados = HorarioBloqueado::whereDate('fecha', $fecha)->get(); $horarios = self::generarHorariosDisponibles($citas, $bloqueados); return response()->json($horarios); } public function getCitasMes(Request $request) { $inicio = Carbon::parse($request->fecha)->startOfMonth(); $fin = Carbon::parse($request->fecha)->endOfMonth(); $citas = Cita::whereBetween('fecha', [$inicio, $fin])->get(); return response()->json($citas); } // --- HELPER: Verificar disponibilidad --- private function verificarDisponibilidad($fecha, $horaInicio, $horaFin) { // Verificar citas existentes $conflicto = Cita::whereDate('fecha', $fecha) ->where('estado', '!=', 'cancelada') ->where(function ($q) use ($horaInicio, $horaFin) { $q->whereBetween('hora_inicio', [$horaInicio, $horaFin]) ->orWhereBetween('hora_fin', [$horaInicio, $horaFin]) ->orWhere(function ($q2) use ($horaInicio, $horaFin) { $q2->where('hora_inicio', '<=', $horaInicio)->where('hora_fin', '>=', $horaFin); }); })->exists(); if ($conflicto) return false; // Verificar horarios bloqueados $bloqueado = HorarioBloqueado::whereDate('fecha', $fecha) ->where(function ($q) use ($horaInicio, $horaFin) { $q->whereBetween('hora_inicio', [$horaInicio, $horaFin]) ->orWhereBetween('hora_fin', [$horaInicio, $horaFin]); })->exists(); return !$bloqueado; } // --- HELPER: Generar array de horarios disponibles --- private function generarHorariosDisponibles($citas, $bloqueados) { $horarios = []; $inicio = 9; // 9:00 AM $fin = 18; // 7:00 PM (último inicio a las 6pm para 60min) for ($hora = $inicio; $hora < $fin; $hora++) { $horaStr = str_pad($hora, 2, '0', STR_PAD_LEFT) . ':00:00'; $horaFinStr = Carbon::parse($horaStr)->addMinutes(60)->format('H:i:s'); // Verificar si está libre $libre = true; foreach ($citas as $cita) { if ($this->horarioConflicta($horaStr, $horaFinStr, $cita->hora_inicio, $cita->hora_fin)) { $libre = false; break; } } if ($libre) { foreach ($bloqueados as $bloqueado) { if ($this->horarioConflicta($horaStr, $horaFinStr, $bloqueado->hora_inicio, $bloqueado->hora_fin)) { $libre = false; break; } } } if ($libre) { $horarios[] = ['hora' => $horaStr, 'label' => Carbon::parse($horaStr)->format('h:i A')]; } } return $horarios; } private function horarioConflicta($inicio, $fin, $existenteInicio, $existenteFin) { return $inicio < $existenteFin && $fin > $existenteInicio; } } ``` ### 3.2 App/Http/Controllers/Admin/HorarioBloqueadoController.php ```php namespace App\Http\Controllers\Admin; class HorarioBloqueadoController extends Controller { public function index() { $bloqueados = HorarioBloqueado::orderBy('fecha')->paginate(15); return view('admin.horarios-bloqueados.index', compact('bloqueados')); } public function store(Request $request) { $request->validate([ 'fecha' => 'required|date', 'hora_inicio' => 'required', 'hora_fin' => 'required', 'motivo' => 'nullable', ]); HorarioBloqueado::create($request->all()); return back()->with('success', 'Horario bloqueado.'); } public function destroy(HorarioBloqueado $bloqueado) { $bloqueado->delete(); return back()->with('success', 'Bloqueo eliminado.'); } } ``` ### 3.3 Actualizar MensajeController para integrar botón de agendar ```php // En show() del MensajeController, pasar flag para mostrar botón public function show(Mensaje $mensaje): View { if (!$mensaje->leido) { $mensaje->marcarLeido(); } $yaTieneCita = Cita::where('mensaje_id', $mensaje->id)->exists(); return view('admin.mensajes.show', compact('mensaje', 'yaTieneCita')); } ``` ## 4. Rutas ### routes/admin.php ```php // Citas Route::prefix('citas')->name('admin.citas.')->group(function () { Route::get('/', [CitaController::class, 'index'])->name('index'); Route::get('/create', [CitaController::class, 'create'])->name('create'); Route::get('/create/{mensaje_id}', [CitaController::class, 'create'])->name('create-from-mensaje'); Route::post('/', [CitaController::class, 'store'])->name('store'); Route::get('/{cita}/edit', [CitaController::class, 'edit'])->name('edit'); Route::put('/{cita}', [CitaController::class, 'update'])->name('update'); Route::delete('/{cita}', [CitaController::class, 'destroy'])->name('destroy'); Route::patch('/{cita}/estado', [CitaController::class, 'cambiarEstado'])->name('estado'); }); // API de disponibilidad Route::get('/citas/disponibles', [CitaController::class, 'getHorariosDisponibles'])->name('admin.citas.disponibles'); Route::get('/citas/calendario', [CitaController::class, 'getCitasMes'])->name('admin.citas.calendario'); // Horarios bloqueados Route::prefix('horarios-bloqueados')->name('admin.horarios.')->group(function () { Route::get('/', [HorarioBloqueadoController::class, 'index'])->name('index'); Route::post('/', [HorarioBloqueadoController::class, 'store'])->name('store'); Route::delete('/{bloqueado}', [HorarioBloqueadoController::class, 'destroy'])->name('destroy'); }); ``` ## 5. Frontend - Vistas ### 5.1 admin/citas/index.blade.php - Tabla con filtros: por fecha, por estado - Calendario mensual (vista) - Buttons: crear, editar, eliminar, cambiar estado ### 5.2 admin/citas/create.blade.php ```html
@csrf
@push('scripts') @endpush ``` ### 5.3 admin/citas/calendario.blade.php (Página completa) - Vista de mes con FullCalendar o tabla HTML - Clic en día muestra horarios disponibles - Arrastrar para crear cita (opcional) ### 5.4 admin/mensajes/show.blade.php (Actualizar) ```html
← Volver @if(!$yaTieneCita) 📅 Agendar Cita @else ✓ Ya tiene cita @endif
``` ### 5.5 admin/horarios-bloqueados/index.blade.php - Formulario para bloquear horarios - Lista de bloqueos activos - Eliminar bloqueo ## 6. Configuración del Negocio (Constantes) ```php // app/Constants/Negocio.php namespace App\Constants; class Negocio { const DURACION_CITA_MINUTOS = 60; const HORA_APERTURA = '09:00'; const HORA_CIERRE = '19:00'; const DIAS_ATENCION = [1, 2, 3, 4, 5, 6]; // Lunes-Sábado (Carbon: 1=Lunes) } ``` ## 7. Flujo de Usuario ### Desde Mensaje: 1. Admin recibe mensaje en admin/mensajes 2. Visualiza el mensaje en admin/mensajes/{id} 3. Ve botón "Agendar Cita" (si no tiene cita) 4. Clic -> admin/citas/create/{mensaje_id} 5. Formulario prellenado con datos del mensaje 6. Selecciona fecha y horario disponible 7. Confirma -> Cita creada -> Redirección a lista de citas ### Adminsitración Directa: 1. Va a admin/citas 2. Clic "Nueva Cita" o usa el calendario 3. Completa formulario 4. Confirma ## 8. Testing Checklist - [ ] Crear cita exitosamente - [ ] Crear cita desde mensaje (datos prellenados) - [ ] Validar conflicto de horarios (dos citas mismo horario) - [ ] Validar horario bloqueado no permite cita - [ ] Cambiar estado de cita (pendiente -> confirmadas -> completada -> cancelada) - [ ] Eliminar cita - [ ] Bloquear horario y verificar no disponible - [ ] Ver calendario con citas del mes - [ ] Validar días no laborables (Domingo) - [ ] Validar horarios fuera de rango (antes 9am, después 7pm) ## 9. Notas Adicionales - Usar SQLite existente (compatible) - Implementar validación de rango de horario en frontend y backend - Considerar agregar notificaciones por email al agendar (futuro) - Los horarios bloqueados deben poder ser recurrentes (opcional v2)