472 lines
14 KiB
Markdown
472 lines
14 KiB
Markdown
# 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
|
|
<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)
|
|
|
|
```html
|
|
<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)
|
|
|
|
```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) |