Add citas module: scheduling, calendar, blocked schedules

This commit is contained in:
2026-04-08 00:48:36 -06:00
parent e19eb205db
commit 91da97685f
21 changed files with 3406 additions and 4 deletions

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\CitaRequest;
use App\Models\Cita;
use App\Models\HorarioBloqueado;
use App\Models\Mensaje;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CitaController extends Controller
{
/**
* Mostrar lista de citas
*/
public function index(Request $request): View
{
$query = Cita::with('mensaje')->orderBy('fecha', 'desc')->orderBy('hora_inicio', 'desc');
// Filtros
if ($request->filled('estado')) {
$query->where('estado', $request->estado);
}
if ($request->filled('fecha')) {
$query->whereDate('fecha', $request->fecha);
}
if ($request->filled('fecha_inicio') && $request->filled('fecha_fin')) {
$query->whereBetween('fecha', [$request->fecha_inicio, $request->fecha_fin]);
}
if ($request->filled('buscar')) {
$buscar = $request->buscar;
$query->where(function ($q) use ($buscar) {
$q->where('nombre_cliente', 'like', "%{$buscar}%")
->orWhere('email_cliente', 'like', "%{$buscar}%")
->orWhere('telefono_cliente', 'like', "%{$buscar}%")
->orWhere('servicio', 'like', "%{$buscar}%");
});
}
$citas = $query->paginate(15)->appends($request->query());
return view('admin.citas.index', compact('citas'));
}
/**
* Mostrar formulario de creación
*/
public function create(): View
{
$mensajes = Mensaje::orderBy('created_at', 'desc')->get();
return view('admin.citas.create', compact('mensajes'));
}
/**
* Guardar nueva cita
*/
public function store(CitaRequest $request): RedirectResponse
{
$data = $request->validated();
// Calcular hora_fin basada en hora_inicio y duracion
$horaInicio = Carbon::parse($data['hora_inicio']);
$horaFin = $horaInicio->addMinutes($data['duracion']);
$data['hora_fin'] = $horaFin->format('H:i:s');
// Verificar disponibilidad antes de crear
$conflicto = Cita::whereDate('fecha', $data['fecha'])
->where(function ($query) use ($data) {
$query->where(function ($q) use ($data) {
$q->whereTime('hora_inicio', '<', $data['hora_fin'])
->whereTime('hora_fin', '>', $data['hora_inicio']);
});
})
->whereIn('estado', ['pendiente', 'confirmada'])
->exists();
if ($conflicto) {
return back()->withErrors(['hora_inicio' => 'Ya existe una cita programada en este horario.'])->withInput();
}
// Verificar si el horario está bloqueado
if (HorarioBloqueado::estaBloqueado($data['fecha'], $data['hora_inicio'])) {
return back()->withErrors(['hora_inicio' => 'El horario está bloqueado.'])->withInput();
}
Cita::create($data);
return redirect()->route('admin.citas.index')->with('success', 'Cita creada correctamente.');
}
/**
* Mostrar detalles de una cita
*/
public function show(Cita $cita): View
{
$cita->load('mensaje');
return view('admin.citas.show', compact('cita'));
}
/**
* Mostrar formulario de edición
*/
public function edit(Cita $cita): View
{
$mensajes = Mensaje::orderBy('created_at', 'desc')->get();
return view('admin.citas.edit', compact('cita', 'mensajes'));
}
/**
* Actualizar cita
*/
public function update(CitaRequest $request, Cita $cita): RedirectResponse
{
$data = $request->validated();
// Calcular hora_fin si cambió hora_inicio o duracion
if ($request->has('hora_inicio') || $request->has('duracion')) {
$horaInicio = Carbon::parse($data['hora_inicio'] ?? $cita->hora_inicio);
$duracion = $data['duracion'] ?? $cita->duracion;
$horaFin = $horaInicio->addMinutes($duracion);
$data['hora_fin'] = $horaFin->format('H:i:s');
}
// Verificar conflicto si se cambia fecha u hora
$conflicto = Cita::where('id', '!=', $cita->id)
->whereDate('fecha', $data['fecha'])
->where(function ($query) use ($data) {
$query->where(function ($q) use ($data) {
$q->whereTime('hora_inicio', '<', $data['hora_fin'])
->whereTime('hora_fin', '>', $data['hora_inicio']);
});
})
->whereIn('estado', ['pendiente', 'confirmada'])
->exists();
if ($conflicto) {
return back()->withErrors(['hora_inicio' => 'Ya existe una cita programada en este horario.'])->withInput();
}
$cita->update($data);
return redirect()->route('admin.citas.index')->with('success', 'Cita actualizada correctamente.');
}
/**
* Eliminar cita
*/
public function destroy(Cita $cita): RedirectResponse
{
$cita->delete();
return redirect()->route('admin.citas.index')->with('success', 'Cita eliminada correctamente.');
}
/**
* Cambiar estado de la cita
*/
public function cambiarEstado(Request $request, Cita $cita): RedirectResponse
{
$request->validate([
'estado' => ['required', 'in:pendiente,confirmada,completada,cancelada'],
]);
$cita->update(['estado' => $request->estado]);
return back()->with('success', 'Estado de la cita actualizado correctamente.');
}
/**
* Mostrar calendario de citas
*/
public function calendario(Request $request): View
{
$fecha = $request->filled('fecha') ? Carbon::parse($request->fecha) : Carbon::now();
$citas = Cita::with('mensaje')
->whereDate('fecha', $fecha)
->whereIn('estado', ['pendiente', 'confirmada'])
->orderBy('hora_inicio')
->get();
return view('admin.citas.calendario', compact('citas', 'fecha'));
}
/**
* Obtener citas para una fecha específica (API)
*/
public function porFecha(Request $request): JsonResponse
{
$request->validate([
'fecha' => ['required', 'date'],
]);
$citas = Cita::whereDate('fecha', $request->fecha)
->whereIn('estado', ['pendiente', 'confirmada'])
->orderBy('hora_inicio')
->get();
return response()->json($citas);
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\HorarioBloqueadoRequest;
use App\Models\HorarioBloqueado;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HorarioBloqueadoController extends Controller
{
/**
* Mostrar lista de horarios bloqueados
*/
public function index(Request $request): View
{
$query = HorarioBloqueado::orderBy('fecha', 'desc')->orderBy('hora_inicio', 'desc');
// Filtros
if ($request->filled('estado')) {
if ($request->estado === 'activos') {
$query->where('activo', true);
} elseif ($request->estado === 'inactivos') {
$query->where('activo', false);
}
}
if ($request->filled('fecha')) {
$query->whereDate('fecha', $request->fecha);
}
if ($request->filled('fecha_inicio') && $request->filled('fecha_fin')) {
$query->whereBetween('fecha', [$request->fecha_inicio, $request->fecha_fin]);
}
$horarios = $query->paginate(15)->appends($request->query());
return view('admin.horarios-bloqueados.index', compact('horarios'));
}
/**
* Mostrar formulario de creación
*/
public function create(): View
{
return view('admin.horarios-bloqueados.create');
}
/**
* Guardar nuevo horario bloqueado
*/
public function store(HorarioBloqueadoRequest $request): RedirectResponse
{
$data = $request->validated();
// Por defecto activo si no se especifica
if (! isset($data['activo'])) {
$data['activo'] = true;
}
// Verificar superposición con otros bloques
$superpuesto = HorarioBloqueado::whereDate('fecha', $data['fecha'])
->where(function ($query) use ($data) {
$query->where(function ($q) use ($data) {
$q->whereTime('hora_inicio', '<', $data['hora_fin'])
->whereTime('hora_fin', '>', $data['hora_inicio']);
});
})
->exists();
if ($superpuesto) {
return back()->withErrors(['hora_inicio' => 'Ya existe un horario bloqueado en este rango.'])->withInput();
}
HorarioBloqueado::create($data);
return redirect()->route('admin.horarios-bloqueados.index')->with('success', 'Horario bloqueado creado correctamente.');
}
/**
* Mostrar detalles de un horario bloqueado
*/
public function show(HorarioBloqueado $horario_bloqueado): View
{
return view('admin.horarios-bloqueados.show', compact('horario_bloqueado'));
}
/**
* Mostrar formulario de edición
*/
public function edit(HorarioBloqueado $horario_bloqueado): View
{
return view('admin.horarios-bloqueados.edit', compact('horario_bloqueado'));
}
/**
* Actualizar horario bloqueado
*/
public function update(HorarioBloqueadoRequest $request, HorarioBloqueado $horario_bloqueado): RedirectResponse
{
$data = $request->validated();
// Verificar superposición con otros bloques (excluyendo el actual)
$superpuesto = HorarioBloqueado::where('id', '!=', $horario_bloqueado->id)
->whereDate('fecha', $data['fecha'])
->where(function ($query) use ($data) {
$query->where(function ($q) use ($data) {
$q->whereTime('hora_inicio', '<', $data['hora_fin'])
->whereTime('hora_fin', '>', $data['hora_inicio']);
});
})
->exists();
if ($superpuesto) {
return back()->withErrors(['hora_inicio' => 'Ya existe un horario bloqueado en este rango.'])->withInput();
}
$horario_bloqueado->update($data);
return redirect()->route('admin.horarios-bloqueados.index')->with('success', 'Horario bloqueado actualizado correctamente.');
}
/**
* Eliminar horario bloqueado
*/
public function destroy(HorarioBloqueado $horario_bloqueado): RedirectResponse
{
$horario_bloqueado->delete();
return redirect()->route('admin.horarios-bloqueados.index')->with('success', 'Horario bloqueado eliminado correctamente.');
}
/**
* Activar horario bloqueado
*/
public function activar(HorarioBloqueado $horario_bloqueado): RedirectResponse
{
$horario_bloqueado->activar();
return back()->with('success', 'Horario bloqueado activado.');
}
/**
* Desactivar horario bloqueado
*/
public function desactivar(HorarioBloqueado $horario_bloqueado): RedirectResponse
{
$horario_bloqueado->desactivar();
return back()->with('success', 'Horario bloqueado desactivado.');
}
/**
* Verificar si una fecha y hora está bloqueada (API)
*/
public function verificar(Request $request): JsonResponse
{
$request->validate([
'fecha' => ['required', 'date'],
'hora' => ['required', 'date_format:H:i'],
]);
$estaBloqueado = HorarioBloqueado::estaBloqueado($request->fecha, $request->hora);
return response()->json([
'bloqueado' => $estaBloqueado,
'mensaje' => $estaBloqueado ? 'El horario está bloqueado.' : 'El horario está disponible.',
]);
}
/**
* Obtener bloques para una fecha específica
*/
public function porFecha(Request $request): JsonResponse
{
$request->validate([
'fecha' => ['required', 'date'],
]);
$bloques = HorarioBloqueado::getBloquesPorFecha($request->fecha);
return response()->json($bloques);
}
/**
* Crear bloqueo rápido desde el calendario
*/
public function bloqueoRapido(Request $request): RedirectResponse
{
$request->validate([
'fecha' => ['required', 'date'],
'hora_inicio' => ['required', 'date_format:H:i'],
'hora_fin' => ['required', 'date_format:H:i', 'after:hora_inicio'],
'motivo' => ['nullable', 'string', 'max:255'],
]);
HorarioBloqueado::create([
'fecha' => $request->fecha,
'hora_inicio' => $request->hora_inicio,
'hora_fin' => $request->hora_fin,
'motivo' => $request->motivo ?? 'Bloqueo rápido',
'activo' => true,
]);
return back()->with('success', 'Horario bloqueado correctamente.');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CitaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$citaId = $this->route('cita')?->id;
return [
'mensaje_id' => ['nullable', 'exists:mensajes,id'],
'nombre_cliente' => ['required', 'string', 'max:255'],
'email_cliente' => ['required', 'email', 'max:255'],
'telefono_cliente' => ['required', 'string', 'max:50'],
'servicio' => ['required', 'string', 'max:255'],
'fecha' => ['required', 'date', 'after_or_equal:today'],
'hora_inicio' => ['required', 'date_format:H:i'],
'hora_fin' => ['nullable', 'date_format:H:i'],
'duracion' => ['required', 'integer', 'min:15', 'max:480'],
'estado' => ['required', Rule::in(['pendiente', 'confirmada', 'completada', 'cancelada'])],
'notas' => ['nullable', 'string'],
];
}
public function messages(): array
{
return [
'fecha.after_or_equal' => 'La fecha debe ser hoy o posterior.',
'estado.in' => 'El estado debe ser: pendiente, confirmada, completada o cancelada.',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class HorarioBloqueadoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fecha' => ['required', 'date'],
'hora_inicio' => ['required', 'date_format:H:i'],
'hora_fin' => ['required', 'date_format:H:i', 'after:hora_inicio'],
'motivo' => ['nullable', 'string', 'max:255'],
'activo' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'hora_fin.after' => 'La hora de fin debe ser posterior a la hora de inicio.',
];
}
}