14 KiB
Executable File
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:
- Admin recibe mensaje en admin/mensajes
- Visualiza el mensaje en admin/mensajes/{id}
- Ve botón "Agendar Cita" (si no tiene cita)
- Clic -> admin/citas/create/{mensaje_id}
- Formulario prellenado con datos del mensaje
- Selecciona fecha y horario disponible
- Confirma -> Cita creada -> Redirección a lista de citas
Adminsitración Directa:
- Va a admin/citas
- Clic "Nueva Cita" o usa el calendario
- Completa formulario
- 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)