Files
lash_vanshy/PLAN_AGENDAMIENTO_CITAS.md

14 KiB
Executable File

Plan Técnico: Módulo de Agendamiento de Citas - Lash Vanshy

1. Estructura de Base de Datos

1.1 Tabla: citas

// 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

// 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

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

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

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

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

// 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

// 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

<form method="POST">
    @csrf
    
    <!-- Datos del cliente (prellenados si viene de mensaje) -->
    <input type="text" name="cliente_nombre" value="{{ $mensaje->nombre ?? old('cliente_nombre') }}">
    <input type="email" name="cliente_email" value="{{ $mensaje->email ?? old('cliente_email') }}">
    <input type="tel" name="cliente_telefono" value="{{ $mensaje->telefono ?? old('cliente_telefono') }}">
    
    <!-- Selector de fecha con calendario -->
    <input type="date" name="fecha" id="fecha" required>
    
    <!-- Selector de hora (se llena vía AJAX según disponibilidad) -->
    <select name="hora_inicio" id="hora_inicio" required>
        <option value="">Seleccione un horario</option>
    </select>
    
    <textarea name="notas" placeholder="Notas adicionales"></textarea>
    
    <button type="submit">Agendar Cita</button>
</form>

@push('scripts')
<script>
document.getElementById('fecha').addEventListener('change', function() {
    fetch(`/admin/citas/disponibles?fecha=${this.value}`)
        .then(r => r.json())
        .then(horarios => {
            const select = document.getElementById('hora_inicio');
            select.innerHTML = '<option value="">Seleccione un horario</option>';
            horarios.forEach(h => {
                select.innerHTML += `<option value="${h.hora}">${h.label}</option>`;
            });
        });
});
</script>
@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)

<div class="actions">
    <a href="{{ route('admin.mensajes.index') }}" class="btn">← Volver</a>
    
    @if(!$yaTieneCita)
    <a href="{{ route('admin.citas.create-from-mensaje', $mensaje->id) }}" class="btn btn-primary">
        📅 Agendar Cita
    </a>
    @else
    <span class="badge">✓ Ya tiene cita</span>
    @endif
</div>

5.5 admin/horarios-bloqueados/index.blade.php

  • Formulario para bloquear horarios
  • Lista de bloqueos activos
  • Eliminar bloqueo

6. Configuración del Negocio (Constantes)

// 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)