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

472
PLAN_AGENDAMIENTO_CITAS.md Normal file
View File

@@ -0,0 +1,472 @@
# 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)

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.',
];
}
}

176
app/Models/Cita.php Normal file
View File

@@ -0,0 +1,176 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Cita extends Model
{
use HasFactory;
protected $table = 'citas';
protected $fillable = [
'mensaje_id',
'nombre_cliente',
'email_cliente',
'telefono_cliente',
'servicio',
'fecha',
'hora_inicio',
'hora_fin',
'duracion',
'estado',
'notas',
];
protected $casts = [
'fecha' => 'date',
'hora_inicio' => 'datetime:H:i',
'hora_fin' => 'datetime:H:i',
'duracion' => 'integer',
];
/**
* Relación con Mensaje
*/
public function mensaje(): BelongsTo
{
return $this->belongsTo(Mensaje::class);
}
/**
* Scope para filtrar citas pendientes
*/
public function scopePendiente(Builder $query): Builder
{
return $query->where('estado', 'pendiente');
}
/**
* Scope para filtrar citas confirmadas
*/
public function scopeConfirmada(Builder $query): Builder
{
return $query->where('estado', 'confirmada');
}
/**
* Scope para filtrar citas completadas
*/
public function scopeCompletada(Builder $query): Builder
{
return $query->where('estado', 'completada');
}
/**
* Scope para filtrar citas canceladas
*/
public function scopeCancelada(Builder $query): Builder
{
return $query->where('estado', 'cancelada');
}
/**
* Scope para filtrar citas por fecha específica
*/
public function scopePorFecha(Builder $query, $fecha): Builder
{
return $query->whereDate('fecha', $fecha);
}
/**
* Scope para filtrar citas entre dos fechas
*/
public function scopeEntreFechas(Builder $query, $inicio, $fin): Builder
{
return $query->whereBetween('fecha', [$inicio, $fin]);
}
/**
* Calcular hora_fin basado en hora_inicio + duracion
*/
public function calcularHoraFin(): string
{
$horaInicio = Carbon::parse($this->hora_inicio);
$horaFin = $horaInicio->addMinutes($this->duracion);
$this->hora_fin = $horaFin->format('H:i:s');
return $this->hora_fin;
}
/**
* Obtener estado formateado para mostrar
*/
public function getEstadoFormateadoAttribute(): string
{
$estados = [
'pendiente' => 'Pendiente',
'confirmada' => 'Confirmada',
'completada' => 'Completada',
'cancelada' => 'Cancelada',
];
return $estados[$this->estado] ?? $this->estado;
}
/**
* Obtener hora de inicio formateada
*/
public function getHoraInicioFormatAttribute(): string
{
return Carbon::parse($this->hora_inicio)->format('H:i');
}
/**
* Obtener hora de fin formateada
*/
public function getHoraFinFormatAttribute(): string
{
return Carbon::parse($this->hora_fin)->format('H:i');
}
/**
* Obtener duración formateada (horas y minutos)
*/
public function getDuracionFormatAttribute(): string
{
$horas = floor($this->duracion / 60);
$minutos = $this->duracion % 60;
if ($horas > 0 && $minutos > 0) {
return "{$horas}h {$minutos}min";
} elseif ($horas > 0) {
return "{$horas}h";
} else {
return "{$minutos}min";
}
}
/**
* Verificar si la cita está activa (no cancelada ni completada)
*/
public function isActiva(): bool
{
return in_array($this->estado, ['pendiente', 'confirmada']);
}
/**
* Cambiar estado de la cita
*/
public function cambiarEstado(string $nuevoEstado): bool
{
$estadosValidos = ['pendiente', 'confirmada', 'completada', 'cancelada'];
if (! in_array($nuevoEstado, $estadosValidos)) {
return false;
}
return $this->update(['estado' => $nuevoEstado]);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class HorarioBloqueado extends Model
{
use HasFactory;
protected $table = 'horarios_bloqueados';
protected $fillable = [
'fecha',
'hora_inicio',
'hora_fin',
'motivo',
'activo',
];
protected $casts = [
'fecha' => 'date',
'activo' => 'boolean',
];
/**
* Scope para filtrar horarios activos
*/
public function scopeActivo(Builder $query): Builder
{
return $query->where('activo', true);
}
/**
* Scope para filtrar horarios por fecha
*/
public function scopePorFecha(Builder $query, $fecha): Builder
{
return $query->whereDate('fecha', $fecha);
}
/**
* Verificar si una fecha y hora está bloqueada
*/
public static function estaBloqueado($fecha, $hora): bool
{
$horaCarbon = Carbon::parse($hora)->format('H:i:s');
return self::whereDate('fecha', $fecha)
->where('activo', true)
->whereTime('hora_inicio', '<=', $horaCarbon)
->whereTime('hora_fin', '>', $horaCarbon)
->exists();
}
/**
* Verificar si una fecha tiene bloques activos
*/
public static function tieneBloques($fecha): bool
{
return self::whereDate('fecha', $fecha)
->where('activo', true)
->exists();
}
/**
* Obtener los bloques activos para una fecha
*/
public static function getBloquesPorFecha($fecha)
{
return self::whereDate('fecha', $fecha)
->where('activo', true)
->orderBy('hora_inicio')
->get();
}
/**
* Activar el horario bloqueado
*/
public function activar(): bool
{
return $this->update(['activo' => true]);
}
/**
* Desactivar el horario bloqueado
*/
public function desactivar(): bool
{
return $this->update(['activo' => false]);
}
/**
* Obtener hora de inicio formateada
*/
public function getHoraInicioFormatAttribute(): string
{
return Carbon::parse($this->hora_inicio)->format('H:i');
}
/**
* Obtener hora de fin formateada
*/
public function getHoraFinFormatAttribute(): string
{
return Carbon::parse($this->hora_fin)->format('H:i');
}
/**
* Obtener rango de horario formateado
*/
public function getRangoHorarioAttribute(): string
{
return "{$this->hora_inicio_format} - {$this->hora_fin_format}";
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('citas', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('mensaje_id')->nullable();
$table->string('nombre_cliente', 255);
$table->string('email_cliente', 255);
$table->string('telefono_cliente', 50);
$table->string('servicio', 255);
$table->date('fecha');
$table->time('hora_inicio');
$table->time('hora_fin');
$table->integer('duracion')->default(60);
$table->enum('estado', ['pendiente', 'confirmada', 'completada', 'cancelada'])->default('pendiente');
$table->text('notas')->nullable();
$table->timestamps();
// Foreign key to mensajes table
$table->foreign('mensaje_id')
->references('id')
->on('mensajes')
->onDelete('set null');
});
}
public function down(): void
{
Schema::dropIfExists('citas');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('horarios_bloqueados', function (Blueprint $table) {
$table->id();
$table->date('fecha');
$table->time('hora_inicio');
$table->time('hora_fin');
$table->string('motivo', 255)->nullable();
$table->boolean('activo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('horarios_bloqueados');
}
};

View File

@@ -604,8 +604,8 @@ body {
}
/* ============================================
SEARCH & FILTERS
============================================ */
SEARCH & FILTERS
============================================ */
.search-box {
position: relative;
max-width: 300px;
@@ -632,6 +632,42 @@ body {
align-items: center;
}
/* ============================================
DROPDOWN MENU IN SIDEBAR
============================================ */
.sidebar-nav .dropdown-menu {
position: static;
background: white;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
margin-left: 1rem;
box-shadow: 0 5px 20px var(--shadow);
}
.sidebar-nav .dropdown-item {
padding: 0.5rem 1rem;
border-radius: 8px;
color: var(--text);
font-weight: 500;
transition: all 0.3s ease;
}
.sidebar-nav .dropdown-item:hover {
background: rgba(248, 180, 196, 0.2);
color: var(--primary-dark);
}
.sidebar-nav .dropdown-item.active {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
}
.sidebar-nav .dropdown-divider {
margin: 0.5rem 0;
border-color: var(--border);
}
/* ============================================
RESPONSIVE
============================================ */

View File

@@ -0,0 +1,478 @@
@extends('admin.layouts.master')
@section('title', 'Calendario de Citas - Lash Vanshy')
@section('page-title', 'Calendario de Citas')
@section('styles')
<!-- FullCalendar CSS -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/main.min.css" rel="stylesheet">
<style>
#calendar {
max-width: 1100px;
margin: 0 auto;
}
.fc {
font-family: var(--font-family);
}
.fc .fc-button {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
border: none;
font-weight: 500;
}
.fc .fc-button:hover {
background: linear-gradient(135deg, var(--primary-dark), var(--primary));
}
.fc .fc-button-primary:not(:disabled).fc-button-active {
background: linear-gradient(135deg, var(--primary-dark), var(--primary));
}
.fc .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text);
}
.fc .fc-daygrid-day-number {
font-weight: 500;
color: var(--text);
}
.fc .fc-col-header-cell-cushion {
font-weight: 600;
color: var(--text);
}
.fc-event {
border: none;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.85rem;
cursor: pointer;
}
.fc-event:hover {
opacity: 0.9;
}
/* Event colors by status */
.fc-event.estado-pendiente {
background: linear-gradient(135deg, #ffc107, #fd7e14);
}
.fc-event.estado-confirmada {
background: linear-gradient(135deg, #28a745, #20c997);
}
.fc-event.estado-completada {
background: linear-gradient(135deg, #17a2b8, var(--primary-dark));
}
.fc-event.estado-cancelada {
background: linear-gradient(135deg, #dc3545, #bd2130);
text-decoration: line-through;
}
/* Bloqueos */
.fc-event.bloqueado {
background: linear-gradient(135deg, #6c757d, #495057);
border: 2px dashed #343a40;
}
/* Today highlight */
.fc .fc-day-today {
background: rgba(248, 180, 196, 0.2) !important;
}
/* Modal styles */
.modal-event {
max-width: 500px;
}
</style>
@endsection
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.citas.index') }}">Citas</a></li>
<li class="breadcrumb-item active" aria-current="page">Calendario</li>
</ol>
</nav>
<!-- Actions Bar -->
<div class="card-admin mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="d-flex gap-2 align-items-center">
<a href="{{ route('admin.citas.create') }}" class="btn btn-primary-admin">
<i class="fas fa-plus me-2"></i>Nueva Cita
</a>
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-list me-2"></i>Ver Lista
</a>
</div>
<div class="d-flex gap-2">
<a href="{{ route('admin.horarios.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-clock me-2"></i>Horarios Bloqueados
</a>
<a href="{{ route('admin.horarios.create') }}" class="btn btn-secondary-admin">
<i class="fas fa-ban me-2"></i>Bloquear Horario
</a>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="card-admin mb-4">
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-4 align-items-center">
<span class="text-muted fw-bold">Leyenda:</span>
<div class="d-flex align-items-center gap-2">
<span class="legend-color" style="background: linear-gradient(135deg, #ffc107, #fd7e14);"></span>
<small>Pendiente</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="legend-color" style="background: linear-gradient(135deg, #28a745, #20c997);"></span>
<small>Confirmada</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="legend-color" style="background: linear-gradient(135deg, #17a2b8, var(--primary-dark));"></span>
<small>Completada</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="legend-color" style="background: linear-gradient(135deg, #dc3545, #bd2130);"></span>
<small>Cancelada</small>
</div>
<div class="d-flex align-items-center gap-2">
<span class="legend-color" style="background: linear-gradient(135deg, #6c757d, #495057); border: 2px dashed #343a40;"></span>
<small>Bloqueado</small>
</div>
</div>
</div>
</div>
<style>
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
display: inline-block;
}
</style>
<!-- Calendar -->
<div class="card-admin">
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
<!-- Event Modal -->
<div class="modal fade" id="eventModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-event">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventModalTitle">Detalles de la Cita</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="eventModalBody">
<!-- Dynamic content -->
</div>
<div class="modal-footer" id="eventModalFooter">
<button type="button" class="btn btn-secondary-admin" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<!-- FullCalendar JS -->
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/locales/es.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const calendarEl = document.getElementById('calendar');
const eventModal = new bootstrap.Modal(document.getElementById('eventModal'));
// Get initial events
fetchEvents().then(events => {
initCalendar(events);
});
async function fetchEvents() {
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const end = new Date(today.getFullYear(), today.getMonth() + 2, 0);
const response = await fetch(
`/admin/citas/por-fecha?inicio=${formatDate(start)}&fin=${formatDate(end)}`
);
const citas = await response.json();
const citasEvents = citas.map(cita => ({
id: `cita-${cita.id}`,
title: `${cita.cliente_nombre} - ${cita.servicio}`,
start: `${cita.fecha}T${cita.hora_inicio}`,
end: `${cita.fecha}T${cita.hora_fin}`,
classNames: [`estado-${cita.estado}`],
extendedProps: {
type: 'cita',
data: cita
}
}));
// Fetch bloqueos
const bloqueosResponse = await fetch(
`/admin/horarios/por-fecha?inicio=${formatDate(start)}&fin=${formatDate(end)}`
);
const bloqueos = await bloqueosResponse.json();
const bloqueosEvents = bloqueos.map(bloqueado => ({
id: `bloqueado-${bloqueado.id}`,
title: `Bloqueado: ${bloqueado.motivo || 'Sin motivo'}`,
start: `${bloqueado.fecha}T${bloqueado.hora_inicio}`,
end: `${bloqueado.fecha}T${bloqueado.hora_fin}`,
classNames: ['bloqueado'],
extendedProps: {
type: 'bloqueado',
data: bloqueado
}
}));
return [...citasEvents, ...bloqueosEvents];
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
function initCalendar(events) {
const calendar = new FullCalendar.Calendar(calendarEl, {
locale: 'es',
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek'
},
events: events,
editable: false,
selectable: true,
selectMirror: true,
dayMaxEvents: 3,
weekends: true,
nowIndicator: true,
slotMinTime: '09:00:00',
slotMaxTime: '19:00:00',
allDaySlot: false,
// Click on event
eventClick: function(info) {
showEventModal(info.event);
},
// Click on date
dateClick: function(info) {
window.location.href = `/admin/citas/create?fecha=${info.dateStr}`;
},
// Dates rendered (fetch more events)
datesSet: function(info) {
refreshEvents(calendar, info.start, info.end);
}
});
calendar.render();
}
async function refreshEvents(calendar, start, end) {
const response = await fetch(
`/admin/citas/por-fecha?inicio=${formatDate(start)}&fin=${formatDate(end)}`
);
const citas = await response.json();
const bloqueosResponse = await fetch(
`/admin/horarios/por-fecha?inicio=${formatDate(start)}&fin=${formatDate(end)}`
);
const bloqueos = await bloqueosResponse.json();
// Remove old events
calendar.getEvents().forEach(event => event.remove());
// Add new events
citas.forEach(cita => {
calendar.addEvent({
id: `cita-${cita.id}`,
title: `${cita.cliente_nombre} - ${cita.servicio}`,
start: `${cita.fecha}T${cita.hora_inicio}`,
end: `${cita.fecha}T${cita.hora_fin}`,
classNames: [`estado-${cita.estado}`],
extendedProps: {
type: 'cita',
data: cita
}
});
});
bloqueos.forEach(bloqueado => {
calendar.addEvent({
id: `bloqueado-${bloqueado.id}`,
title: `Bloqueado: ${bloqueado.motivo || 'Sin motivo'}`,
start: `${bloqueado.fecha}T${bloqueado.hora_inicio}`,
end: `${bloqueado.fecha}T${bloqueado.hora_fin}`,
classNames: ['bloqueado'],
extendedProps: {
type: 'bloqueado',
data: bloqueado
}
});
});
}
function showEventModal(event) {
const props = event.extendedProps;
const titleEl = document.getElementById('eventModalTitle');
const bodyEl = document.getElementById('eventModalBody');
const footerEl = document.getElementById('eventModalFooter');
if (props.type === 'cita') {
const cita = props.data;
titleEl.textContent = `Cita: ${cita.cliente_nombre}`;
bodyEl.innerHTML = `
<div class="d-flex flex-column gap-3">
<div>
<label class="text-muted small">Fecha y Hora</label>
<p class="mb-0 fw-bold">
<i class="fas fa-calendar-alt me-2"></i>
${formatDateDisplay(cita.fecha)}
${formatTime(cita.hora_inicio)} - ${formatTime(cita.hora_fin)}
</p>
</div>
<div>
<label class="text-muted small">Cliente</label>
<p class="mb-0">${cita.cliente_nombre}</p>
<p class="mb-0 small"><a href="mailto:${cita.cliente_email}">${cita.cliente_email}</a></p>
${cita.cliente_telefono ? `<p class="mb-0 small"><a href="tel:${cita.cliente_telefono}">${cita.cliente_telefono}</a></p>` : ''}
</div>
<div>
<label class="text-muted small">Servicio</label>
<p class="mb-0">${cita.servicio}</p>
</div>
<div>
<label class="text-muted small">Estado</label>
<p class="mb-0">
<span class="badge-admin bg-${getBadgeClass(cita.estado)}">
${getEstadoLabel(cita.estado)}
</span>
</p>
</div>
${cita.notas ? `
<div>
<label class="text-muted small">Notas</label>
<p class="mb-0">${cita.notas}</p>
</div>
` : ''}
</div>
`;
footerEl.innerHTML = `
<a href="/admin/citas/${cita.id}" class="btn btn-secondary-admin">
<i class="fas fa-eye me-2"></i>Ver Detalles
</a>
<a href="/admin/citas/${cita.id}/edit" class="btn btn-primary-admin">
<i class="fas fa-edit me-2"></i>Editar
</a>
`;
} else {
const bloqueado = props.data;
titleEl.textContent = 'Horario Bloqueado';
bodyEl.innerHTML = `
<div class="d-flex flex-column gap-3">
<div>
<label class="text-muted small">Fecha</label>
<p class="mb-0 fw-bold">
<i class="fas fa-calendar-alt me-2"></i>
${formatDateDisplay(bloqueado.fecha)}
</p>
</div>
<div>
<label class="text-muted small">Horario</label>
<p class="mb-0">
${formatTime(bloqueado.hora_inicio)} - ${formatTime(bloqueado.hora_fin)}
</p>
</div>
${bloqueado.motivo ? `
<div>
<label class="text-muted small">Motivo</label>
<p class="mb-0">${bloqueado.motivo}</p>
</div>
` : ''}
</div>
`;
footerEl.innerHTML = `
<button type="button" class="btn btn-secondary-admin" data-bs-dismiss="modal">Cerrar</button>
<a href="/admin/horarios/${bloqueado.id}/edit" class="btn btn-primary-admin">
<i class="fas fa-edit me-2"></i>Editar
</a>
`;
}
eventModal.show();
}
function formatDateDisplay(fecha) {
const date = new Date(fecha + 'T00:00:00');
return date.toLocaleDateString('es-ES', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function formatTime(hora) {
const [hours, minutes] = hora.split(':');
const date = new Date();
date.setHours(parseInt(hours));
date.setMinutes(parseInt(minutes));
return date.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
}
function getBadgeClass(estado) {
const classes = {
'pendiente': 'warning',
'confirmada': 'success',
'completada': 'info',
'cancelada': 'danger'
};
return classes[estado] || 'secondary';
}
function getEstadoLabel(estado) {
const labels = {
'pendiente': 'Pendiente',
'confirmada': 'Confirmada',
'completada': 'Completada',
'cancelada': 'Cancelada'
};
return labels[estado] || estado;
}
});
</script>
@endpush

View File

@@ -0,0 +1,293 @@
@extends('admin.layouts.master')
@section('title', 'Crear Cita - Lash Vanshy')
@section('page-title', 'Nueva Cita')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.citas.index') }}">Citas</a></li>
<li class="breadcrumb-item active" aria-current="page">Nueva Cita</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<div class="card-admin">
<div class="card-header">
<i class="fas fa-calendar-plus me-2"></i>Agendar Nueva Cita
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.citas.store') }}" id="citaForm">
@csrf
@if($mensaje)
<input type="hidden" name="mensaje_id" value="{{ $mensaje->id }}">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
<strong>Nota:</strong> Esta cita se creará a partir del mensaje de
<strong>{{ $mensaje->nombre }}</strong>
<a href="{{ route('admin.mensajes.show', $mensaje) }}" class="text-decoration-underline ms-1">
(Ver mensaje)
</a>
</div>
@endif
<div class="row">
<!-- Cliente Nombre -->
<div class="col-md-6 mb-3">
<label for="cliente_nombre" class="form-label">
Nombre del Cliente <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control @error('cliente_nombre') is-invalid @enderror"
id="cliente_nombre"
name="cliente_nombre"
value="{{ $mensaje->nombre ?? old('cliente_nombre') }}"
required>
@error('cliente_nombre')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Cliente Email -->
<div class="col-md-6 mb-3">
<label for="cliente_email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email"
class="form-control @error('cliente_email') is-invalid @enderror"
id="cliente_email"
name="cliente_email"
value="{{ $mensaje->email ?? old('cliente_email') }}"
required>
@error('cliente_email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Cliente Teléfono -->
<div class="col-md-6 mb-3">
<label for="cliente_telefono" class="form-label">
Teléfono
</label>
<input type="tel"
class="form-control @error('cliente_telefono') is-invalid @enderror"
id="cliente_telefono"
name="cliente_telefono"
value="{{ $mensaje->telefono ?? old('cliente_telefono') }}">
@error('cliente_telefono')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Servicio -->
<div class="col-md-6 mb-3">
<label for="servicio" class="form-label">
Servicio
</label>
<select class="form-select @error('servicio') is-invalid @enderror"
id="servicio"
name="servicio">
<option value="Lash Extensions" selected>Lash Extensions</option>
<option value="Lash Lift">Lash Lift</option>
<option value="Lash Removal">Lash Removal</option>
<option value="Relleno">Relleno</option>
<option value="Otro">Otro</option>
</select>
</div>
</div>
<hr class="my-4">
<div class="row">
<!-- Fecha -->
<div class="col-md-6 mb-3">
<label for="fecha" class="form-label">
Fecha <span class="text-danger">*</span>
</label>
<input type="date"
class="form-control @error('fecha') is-invalid @enderror"
id="fecha"
name="fecha"
value="{{ old('fecha') }}"
min="{{ date('Y-m-d') }}"
required>
@error('fecha')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Horario: 9:00 AM - 7:00 PM (Lun-Sáb)
</small>
@enderror
</div>
<!-- Hora Inicio -->
<div class="col-md-6 mb-3">
<label for="hora_inicio" class="form-label">
Hora <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_inicio') is-invalid @enderror"
id="hora_inicio"
name="hora_inicio"
required
{{ old('fecha') ? '' : 'disabled' }}>
<option value="">Seleccione fecha primero</option>
@if(old('fecha'))
@php
$oldFecha = old('fecha');
$oldHora = old('hora_inicio');
@endphp
@foreach($horariosDisponibles ?? [] as $hora)
<option value="{{ $hora['hora'] }}"
{{ $oldHora == $hora['hora'] ? 'selected' : '' }}>
{{ $hora['label'] }}
</option>
@endforeach
@endif
</select>
@error('hora_inicio')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted" id="horaHelp">
Duración: 60 minutos
</small>
@enderror
<div class="loading-spinner d-none" id="horaLoading">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Notas -->
<div class="mb-3">
<label for="notas" class="form-label">Notas</label>
<textarea class="form-control @error('notas') is-invalid @enderror"
id="notas"
name="notas"
rows="3"
placeholder="Notas adicionales sobre la cita...">{{ old('notas') }}</textarea>
@error('notas')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="d-flex justify-content-between mt-4">
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<button type="submit" class="btn btn-primary-admin" id="submitBtn">
<i class="fas fa-calendar-check me-2"></i>Agendar Cita
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>Información
</div>
<div class="card-body">
<ul class="text-muted">
<li class="mb-2">Duración de cada cita: <strong>60 minutos</strong></li>
<li class="mb-2">Horario de atención: <strong>9:00 AM - 7:00 PM</strong></li>
<li class="mb-2">Días laborables: <strong>Lunes a Sábado</strong></li>
<li class="mb-2">Último turno: <strong>6:00 PM</strong></li>
</ul>
</div>
</div>
<div class="card-admin">
<div class="card-header">
<i class="fas fa-calendar-alt me-2"></i>Calendario
</div>
<div class="card-body">
<a href="{{ route('admin.citas.calendario') }}" class="btn btn-secondary-admin w-100 mb-2">
<i class="fas fa-calendar-alt me-2"></i>Ver Calendario
</a>
<a href="{{ route('admin.horarios.index') }}" class="btn btn-secondary-admin w-100">
<i class="fas fa-clock me-2"></i>Horarios Bloqueados
</a>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const fechaInput = document.getElementById('fecha');
const horaSelect = document.getElementById('hora_inicio');
const horaLoading = document.getElementById('horaLoading');
const horaHelp = document.getElementById('horaHelp');
const hoy = new Date().toISOString().split('T')[0];
fechaInput.setAttribute('min', hoy);
// Sundays disabled
fechaInput.addEventListener('change', function() {
const fecha = new Date(this.value + 'T00:00:00');
const diaSemana = fecha.getDay();
if (diaSemana === 0) {
alert('Los domingos no hay atención.');
this.value = '';
horaSelect.innerHTML = '<option value="">Seleccione fecha primero</option>';
horaSelect.disabled = true;
return;
}
cargarHorariosDisponibles(this.value);
});
function cargarHorariosDisponibles(fecha) {
if (!fecha) return;
horaSelect.disabled = true;
horaLoading.classList.remove('d-none');
horaHelp.classList.add('d-none');
fetch(`/admin/citas/disponibles?fecha=${fecha}`)
.then(response => response.json())
.then(data => {
horaSelect.innerHTML = '<option value="">Seleccione un horario</option>';
if (data.length === 0) {
horaSelect.innerHTML = '<option value="">No hay horarios disponibles</option>';
} else {
data.forEach(hora => {
const option = document.createElement('option');
option.value = hora.hora;
option.textContent = hora.label;
horaSelect.appendChild(option);
});
}
horaSelect.disabled = false;
})
.catch(error => {
console.error('Error:', error);
horaSelect.innerHTML = '<option value="">Error al cargar horarios</option>';
horaSelect.disabled = false;
})
.finally(() => {
horaLoading.classList.add('d-none');
horaHelp.classList.remove('d-none');
});
}
// Trigger on page load if fecha has value
if (fechaInput.value) {
cargarHorariosDisponibles(fechaInput.value);
}
});
</script>
@endpush

View File

@@ -0,0 +1,226 @@
@extends('admin.layouts.master')
@section('title', 'Editar Cita - Lash Vanshy')
@section('page-title', 'Editar Cita')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.citas.index') }}">Citas</a></li>
<li class="breadcrumb-item active" aria-current="page">Editar Cita</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<div class="card-admin">
<div class="card-header">
<i class="fas fa-edit me-2"></i>Editar Cita
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.citas.update', $cita) }}">
@csrf
@method('PUT')
<div class="row">
<!-- Cliente Nombre -->
<div class="col-md-6 mb-3">
<label for="cliente_nombre" class="form-label">
Nombre del Cliente <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control @error('cliente_nombre') is-invalid @enderror"
id="cliente_nombre"
name="cliente_nombre"
value="{{ old('cliente_nombre', $cita->cliente_nombre) }}"
required>
@error('cliente_nombre')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Cliente Email -->
<div class="col-md-6 mb-3">
<label for="cliente_email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email"
class="form-control @error('cliente_email') is-invalid @enderror"
id="cliente_email"
name="cliente_email"
value="{{ old('cliente_email', $cita->cliente_email) }}"
required>
@error('cliente_email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Cliente Teléfono -->
<div class="col-md-6 mb-3">
<label for="cliente_telefono" class="form-label">
Teléfono
</label>
<input type="tel"
class="form-control @error('cliente_telefono') is-invalid @enderror"
id="cliente_telefono"
name="cliente_telefono"
value="{{ old('cliente_telefono', $cita->cliente_telefono) }}">
@error('cliente_telefono')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Servicio -->
<div class="col-md-6 mb-3">
<label for="servicio" class="form-label">
Servicio
</label>
<select class="form-select @error('servicio') is-invalid @enderror"
id="servicio"
name="servicio">
<option value="Lash Extensions" {{ old('servicio', $cita->servicio) == 'Lash Extensions' ? 'selected' : '' }}>Lash Extensions</option>
<option value="Lash Lift" {{ old('servicio', $cita->servicio) == 'Lash Lift' ? 'selected' : '' }}>Lash Lift</option>
<option value="Lash Removal" {{ old('servicio', $cita->servicio) == 'Lash Removal' ? 'selected' : '' }}>Lash Removal</option>
<option value="Relleno" {{ old('servicio', $cita->servicio) == 'Relleno' ? 'selected' : '' }}>Relleno</option>
<option value="Otro" {{ old('servicio', $cita->servicio) == 'Otro' ? 'selected' : '' }}>Otro</option>
</select>
</div>
</div>
<hr class="my-4">
<div class="row">
<!-- Fecha -->
<div class="col-md-6 mb-3">
<label for="fecha" class="form-label">
Fecha <span class="text-danger">*</span>
</label>
<input type="date"
class="form-control @error('fecha') is-invalid @enderror"
id="fecha"
name="fecha"
value="{{ old('fecha', $cita->fecha->format('Y-m-d')) }}"
min="{{ date('Y-m-d') }}"
required>
@error('fecha')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Horario: 9:00 AM - 7:00 PM
</small>
@enderror
</div>
<!-- Hora Inicio -->
<div class="col-md-6 mb-3">
<label for="hora_inicio" class="form-label">
Hora <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_inicio') is-invalid @enderror"
id="hora_inicio"
name="hora_inicio"
required
disabled>
<option value="{{ $cita->hora_inicio }}">{{ \Carbon\Carbon::parse($cita->hora_inicio)->format('h:i A') }}</option>
</select>
@error('hora_inicio')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted" id="horaHelp">
Duración: 60 minutos
</small>
@enderror
<div class="loading-spinner d-none" id="horaLoading">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Estado -->
<div class="mb-3">
<label for="estado" class="form-label">Estado</label>
<select class="form-select @error('estado') is-invalid @enderror"
id="estado"
name="estado">
<option value="pendiente" {{ old('estado', $cita->estado) == 'pendiente' ? 'selected' : '' }}>Pendiente</option>
<option value="confirmada" {{ old('estado', $cita->estado) == 'confirmada' ? 'selected' : '' }}>Confirmada</option>
<option value="completada" {{ old('estado', $cita->estado) == 'completada' ? 'selected' : '' }}>Completada</option>
<option value="cancelada" {{ old('estado', $cita->estado) == 'cancelada' ? 'selected' : '' }}>Cancelada</option>
</select>
</div>
<!-- Notas -->
<div class="mb-3">
<label for="notas" class="form-label">Notas</label>
<textarea class="form-control @error('notas') is-invalid @enderror"
id="notas"
name="notas"
rows="3"
placeholder="Notas adicionales...">{{ old('notas', $cita->notas) }}</textarea>
@error('notas')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="d-flex justify-content-between mt-4">
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar Info -->
<div class="col-lg-4">
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>Información de la Cita
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Fecha Creación:</dt>
<dd class="col-sm-7">{{ $cita->created_at->format('d/m/Y H:i') }}</dd>
<dt class="col-sm-5">Última Actualización:</dt>
<dd class="col-sm-7">{{ $cita->updated_at->format('d/m/Y H:i') }}</dd>
@if($cita->mensaje)
<dt class="col-sm-5">Desde Mensaje:</dt>
<dd class="col-sm-7">
<a href="{{ route('admin.mensajes.show', $cita->mensaje) }}">Ver mensaje</a>
</dd>
@endif
</dl>
</div>
</div>
<div class="card-admin">
<div class="card-header">
<i class="fas fa-exclamation-triangle me-2"></i>¿Cancelar Cita?
</div>
<div class="card-body">
<p class="text-muted mb-3">
¿Necesita cancelar esta cita? Puede cambiar el estado a "Cancelada" desde el formulario o eliminarla permanentemente.
</p>
<form action="{{ route('admin.citas.destroy', $cita) }}" method="POST"
onsubmit="return confirm('¿Estás seguro de que deseas eliminar esta cita? Esta acción no se puede deshacer.')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger-admin w-100">
<i class="fas fa-trash me-2"></i>Eliminar Cita
</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,272 @@
@extends('admin.layouts.master')
@section('title', 'Citas - Lash Vanshy')
@section('page-title', 'Citas')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item active" aria-current="page">Citas</li>
</ol>
</nav>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-calendar-check"></i>
</div>
<div class="stat-info">
<h3>{{ $stats['pendientes'] ?? 0 }}</h3>
<p>Pendientes</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3>{{ $stats['confirmadas'] ?? 0 }}</h3>
<p>Confirmadas</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon info">
<i class="fas fa-calendar-check"></i>
</div>
<div class="stat-info">
<h3>{{ $stats['completadas'] ?? 0 }}</h3>
<p>Completadas</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon warning">
<i class="fas fa-times-circle"></i>
</div>
<div class="stat-info">
<h3>{{ $stats['canceladas'] ?? 0 }}</h3>
<p>Canceladas</p>
</div>
</div>
</div>
</div>
<!-- Filters & Actions -->
<div class="card-admin mb-4">
<div class="card-body">
<div class="filters-bar">
<form method="GET" class="d-flex gap-3 flex-wrap align-items-center">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" name="buscar" class="form-control"
placeholder="Buscar cliente..."
value="{{ request('buscar') }}">
</div>
<div class="search-box">
<i class="fas fa-calendar"></i>
<input type="date" name="fecha" class="form-control"
value="{{ request('fecha') }}">
</div>
<select name="estado" class="form-select" style="width: auto;">
<option value="">Todos los estados</option>
<option value="pendiente" {{ request('estado') == 'pendiente' ? 'selected' : '' }}>Pendiente</option>
<option value="confirmada" {{ request('estado') == 'confirmada' ? 'selected' : '' }}>Confirmada</option>
<option value="completada" {{ request('estado') == 'completada' ? 'selected' : '' }}>Completada</option>
<option value="cancelada" {{ request('estado') == 'cancelada' ? 'selected' : '' }}>Cancelada</option>
</select>
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-filter me-2"></i>Filtrar
</button>
@if(request()->hasAny(['buscar', 'fecha', 'estado']))
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-times me-2"></i>Limpiar
</a>
@endif
</form>
<div class="d-flex gap-2 ms-auto">
<a href="{{ route('admin.citas.create') }}" class="btn btn-primary-admin">
<i class="fas fa-plus me-2"></i>Nueva Cita
</a>
<a href="{{ route('admin.citas.calendario') }}" class="btn btn-secondary-admin">
<i class="fas fa-calendar-alt me-2"></i>Calendario
</a>
</div>
</div>
</div>
</div>
<!-- Citas Table -->
<div class="card-admin">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-calendar me-2"></i>Listado de Citas
</span>
<span class="text-muted small">
Total: {{ $citas->total() }} citas
</span>
</div>
<div class="card-body p-0">
@if($citas->isEmpty())
<div class="empty-state">
<i class="fas fa-calendar-times"></i>
<h4>No hay citas</h4>
<p>No se encontraron citas con los filtros seleccionados.</p>
<a href="{{ route('admin.citas.create') }}" class="btn btn-primary-admin mt-3">
<i class="fas fa-plus me-2"></i>Crear Primera Cita
</a>
</div>
@else
<div class="table-responsive">
<table class="table-admin">
<thead>
<tr>
<th>Fecha/Hora</th>
<th>Cliente</th>
<th>Servicio</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach($citas as $cita)
<tr>
<td>
<div class="d-flex flex-column">
<span class="fw-bold">
<i class="fas fa-calendar-alt me-1 text-primary"></i>
{{ $cita->fecha->format('d/m/Y') }}
</span>
<span class="text-muted small">
{{ $cita->hora_inicio }} - {{ $cita->hora_fin }}
</span>
</div>
</td>
<td>
<div class="d-flex flex-column">
<span class="fw-bold">{{ $cita->cliente_nombre }}</span>
<span class="text-muted small">{{ $cita->cliente_email }}</span>
@if($cita->cliente_telefono)
<span class="text-muted small">{{ $cita->cliente_telefono }}</span>
@endif
</div>
</td>
<td>{{ $cita->servicio }}</td>
<td>
@switch($cita->estado)
@case('pendiente')
<span class="badge-admin bg-warning">Pendiente</span>
@break
@case('confirmada')
<span class="badge-admin bg-success">Confirmada</span>
@break
@case('completada')
<span class="badge-admin bg-info">Completada</span>
@break
@case('cancelada')
<span class="badge-admin bg-danger">Cancelada</span>
@break
@endswitch
</td>
<td>
<div class="actions">
<a href="{{ route('admin.citas.show', $cita) }}"
class="btn btn-sm btn-secondary-admin"
title="Ver detalles">
<i class="fas fa-eye"></i>
</a>
<a href="{{ route('admin.citas.edit', $cita) }}"
class="btn btn-sm btn-secondary-admin"
title="Editar">
<i class="fas fa-edit"></i>
</a>
<div class="dropdown">
<button class="btn btn-sm btn-secondary-admin dropdown-toggle"
data-bs-toggle="dropdown"
title="Cambiar estado">
<i class="fas fa-flag"></i>
</button>
<ul class="dropdown-menu">
<li>
<form action="{{ route('admin.citas.estado', $cita) }}" method="POST">
@csrf
@method('PATCH')
<input type="hidden" name="estado" value="pendiente">
<button type="submit" class="dropdown-item">
<i class="fas fa-clock me-2"></i>Pendiente
</button>
</form>
</li>
<li>
<form action="{{ route('admin.citas.estado', $cita) }}" method="POST">
@csrf
@method('PATCH')
<input type="hidden" name="estado" value="confirmada">
<button type="submit" class="dropdown-item">
<i class="fas fa-check me-2"></i>Confirmar
</button>
</form>
</li>
<li>
<form action="{{ route('admin.citas.estado', $cita) }}" method="POST">
@csrf
@method('PATCH')
<input type="hidden" name="estado" value="completada">
<button type="submit" class="dropdown-item">
<i class="fas fa-check-circle me-2"></i>Completar
</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form action="{{ route('admin.citas.estado', $cita) }}" method="POST">
@csrf
@method('PATCH')
<input type="hidden" name="estado" value="cancelada">
<button type="submit" class="dropdown-item text-danger">
<i class="fas fa-times me-2"></i>Cancelar
</button>
</form>
</li>
</ul>
</div>
<form action="{{ route('admin.citas.destroy', $cita) }}" method="POST"
onsubmit="return confirm('¿Estás seguro de eliminar esta cita?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger-admin" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-wrapper p-3">
{{ $citas->withQueryString()->links() }}
</div>
@endif
</div>
</div>
@endsection

View File

@@ -0,0 +1,199 @@
@extends('admin.layouts.master')
@section('title', 'Ver Cita - Lash Vanshy')
@section('page-title', 'Detalle de Cita')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.citas.index') }}">Citas</a></li>
<li class="breadcrumb-item active" aria-current="page">Ver Cita</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<div class="card-admin">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-calendar me-2"></i>Cita de {{ $cita->cliente_nombre }}
</span>
@switch($cita->estado)
@case('pendiente')
<span class="badge-admin bg-warning">Pendiente</span>
@break
@case('confirmada')
<span class="badge-admin bg-success">Confirmada</span>
@break
@case('completada')
<span class="badge-admin bg-info">Completada</span>
@break
@case('cancelada')
<span class="badge-admin bg-danger">Cancelada</span>
@break
@endswitch
</div>
<div class="card-body">
<!-- Date & Time -->
<div class="row mb-4">
<div class="col-md-6">
<label class="text-muted small">Fecha</label>
<p class="mb-0 fw-bold">
<i class="fas fa-calendar-alt me-2 text-primary"></i>
{{ $cita->fecha->format('d/m/Y') }}
</p>
</div>
<div class="col-md-6">
<label class="text-muted small">Horario</label>
<p class="mb-0 fw-bold">
<i class="fas fa-clock me-2 text-primary"></i>
{{ \Carbon\Carbon::parse($cita->hora_inicio)->format('h:i A') }}
-
{{ \Carbon\Carbon::parse($cita->hora_fin)->format('h:i A') }}
</p>
<small class="text-muted">Duración: 60 minutos</small>
</div>
</div>
<hr>
<!-- Client Info -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<label class="text-muted small">Nombre del Cliente</label>
<p class="mb-0 fw-bold">{{ $cita->cliente_nombre }}</p>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small">Email</label>
<p class="mb-0">
<a href="mailto:{{ $cita->cliente_email }}">{{ $cita->cliente_email }}</a>
</p>
</div>
@if($cita->cliente_telefono)
<div class="col-md-6 mb-3">
<label class="text-muted small">Teléfono</label>
<p class="mb-0">
<a href="tel:{{ $cita->cliente_telefono }}">{{ $cita->cliente_telefono }}</a>
</p>
</div>
@endif
<div class="col-md-6 mb-3">
<label class="text-muted small">Servicio</label>
<p class="mb-0">{{ $cita->servicio }}</p>
</div>
</div>
@if($cita->notas)
<hr>
<!-- Notes -->
<div class="mb-4">
<label class="text-muted small d-block mb-2">Notas</label>
<div class="p-3 bg-light rounded">
{{ $cita->notas }}
</div>
</div>
@endif
<hr>
<!-- Metadata -->
<div class="row text-muted small">
<div class="col-md-6">
<label class="text-muted small">Fecha de creación</label>
<p class="mb-0">{{ $cita->created_at->format('d/m/Y H:i') }}</p>
</div>
<div class="col-md-6">
<label class="text-muted small">Última actualización</label>
<p class="mb-0">{{ $cita->updated_at->format('d/m/Y H:i') }}</p>
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<div class="d-flex gap-2">
<a href="{{ route('admin.citas.edit', $cita) }}" class="btn btn-primary-admin">
<i class="fas fa-edit me-2"></i>Editar
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Status Change -->
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-flag me-2"></i>Cambiar Estado
</div>
<div class="card-body">
<form action="{{ route('admin.citas.estado', $cita) }}" method="POST" class="d-flex flex-column gap-2">
@csrf
@method('PATCH')
<button type="submit" name="estado" value="pendiente"
class="btn btn-sm {{ $cita->estado == 'pendiente' ? 'btn-primary-admin' : 'btn-secondary-admin' }}">
<i class="fas fa-clock me-2"></i>Pendiente
</button>
<button type="submit" name="estado" value="confirmada"
class="btn btn-sm {{ $cita->estado == 'confirmada' ? 'btn-primary-admin' : 'btn-secondary-admin' }}">
<i class="fas fa-check me-2"></i>Confirmar
</button>
<button type="submit" name="estado" value="completada"
class="btn btn-sm {{ $cita->estado == 'completada' ? 'btn-primary-admin' : 'btn-secondary-admin' }}">
<i class="fas fa-check-circle me-2"></i>Completar
</button>
<button type="submit" name="estado" value="cancelada"
class="btn btn-sm {{ $cita->estado == 'cancelada' ? 'btn-danger-admin' : 'btn-secondary-admin' }}">
<i class="fas fa-times me-2"></i>Cancelar
</button>
</form>
</div>
</div>
<!-- Related Message -->
@if($cita->mensaje)
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-envelope me-2"></i>Mensaje Relacionado
</div>
<div class="card-body">
<p class="mb-2"><strong>{{ $cita->mensaje->nombre }}</strong></p>
<p class="text-muted small mb-3">{{ $cita->mensaje->mensaje }}</p>
<a href="{{ route('admin.mensajes.show', $cita->mensaje) }}" class="btn btn-secondary-admin w-100">
<i class="fas fa-eye me-2"></i>Ver Mensaje
</a>
</div>
</div>
@endif
<!-- Danger Zone -->
<div class="card-admin">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Zona de Peligro
</div>
<div class="card-body">
<p class="text-muted mb-3">
Eliminar esta cita es una acción permanente. ¿Estás seguro?
</p>
<form action="{{ route('admin.citas.destroy', $cita) }}" method="POST"
onsubmit="return confirm('¿Estás seguro de eliminar esta cita? Esta acción no se puede deshacer.')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger-admin w-100">
<i class="fas fa-trash me-2"></i>Eliminar Cita
</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,209 @@
@extends('admin.layouts.master')
@section('title', 'Bloquear Horario - Lash Vanshy')
@section('page-title', 'Bloquear Horario')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.horarios.index') }}">Horarios Bloqueados</a></li>
<li class="breadcrumb-item active" aria-current="page">Bloquear Horario</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<div class="card-admin">
<div class="card-header">
<i class="fas fa-ban me-2"></i>Bloquear Horario
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.horarios.store') }}">
@csrf
<div class="row">
<!-- Fecha -->
<div class="col-md-6 mb-3">
<label for="fecha" class="form-label">
Fecha <span class="text-danger">*</span>
</label>
<input type="date"
class="form-control @error('fecha') is-invalid @enderror"
id="fecha"
name="fecha"
value="{{ old('fecha') }}"
min="{{ date('Y-m-d') }}"
required>
@error('fecha')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
No se podrán agendar citas en esta fecha
</small>
@enderror
</div>
</div>
<div class="row">
<!-- Hora Inicio -->
<div class="col-md-6 mb-3">
<label for="hora_inicio" class="form-label">
Hora de Inicio <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_inicio') is-invalid @enderror"
id="hora_inicio"
name="hora_inicio"
required>
<option value="">Seleccionar hora</option>
@for($hora = 9; $hora < 19; $hora++)
<option value="{{ sprintf('%02d:00:00', $hora) }}"
{{ old('hora_inicio') == sprintf('%02d:00:00', $hora) ? 'selected' : '' }}>
{{ sprintf('%02d:00', $hora) }} AM
</option>
@endfor
@for($hora = 12; $hora < 19; $hora++)
<option value="{{ sprintf('%02d:00:00', $hora) }}"
{{ old('hora_inicio') == sprintf('%02d:00:00', $hora) ? 'selected' : '' }}>
{{ $hora > 12 ? sprintf('%02d:00', $hora - 12) : sprintf('%02d:00', $hora) }}:00 {{ $hora >= 12 ? 'PM' : 'AM' }}
</option>
@endfor
</select>
@error('hora_inicio')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Hora Fin -->
<div class="col-md-6 mb-3">
<label for="hora_fin" class="form-label">
Hora de Fin <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_fin') is-invalid @enderror"
id="hora_fin"
name="hora_fin"
required>
<option value="">Seleccionar hora</option>
@for($hora = 9; $hora < 19; $hora++)
<option value="{{ sprintf('%02d:00:00', $hora) }}"
{{ old('hora_fin') == sprintf('%02d:00:00', $hora) ? 'selected' : '' }}>
{{ sprintf('%02d:00', $hora) }}:00 {{ $hora >= 12 ? 'PM' : 'AM' }}
</option>
@endfor
</select>
@error('hora_fin')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted">
El bloqueo dura hasta esta hora
</small>
@enderror
</div>
</div>
<!-- Motivo -->
<div class="mb-3">
<label for="motivo" class="form-label">Motivo</label>
<input type="text"
class="form-control @error('motivo') is-invalid @enderror"
id="motivo"
name="motivo"
value="{{ old('motivo') }}"
placeholder="Ej: Vacaciones, Mantenimiento, Evento especial...">
@error('motivo')
<div class="invalid-feedback">{{ $message }}</div>
@else
<small class="text-muted">
Opcional. Ej: "Vacaciones de Semana Santa"
</small>
@enderror
</div>
<!-- Quick Options -->
<div class="mb-4">
<label class="form-label">Opciones Rápidas</label>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary-admin btn-sm" onclick="bloquearDiaCompleto()">
<i class="fas fa-calendar me-2"></i>Bloquear Día Completo
</button>
<button type="button" class="btn btn-secondary-admin btn-sm" onclick="bloquearManana()">
<i class="fas fa-sun me-2"></i>Bloquear Mañana (9am-12pm)
</button>
<button type="button" class="btn btn-secondary-admin btn-sm" onclick="bloquearTarde()">
<i class="fas fa-moon me-2"></i>Bloquear Tarde (12pm-7pm)
</button>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<a href="{{ route('admin.horarios.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-lock me-2"></i>Bloquear Horario
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>Información
</div>
<div class="card-body">
<ul class="text-muted">
<li class="mb-2">Los horarios bloqueados prevents the agendamiento de citas.</li>
<li class="mb-2">Puedes bloquear días completos o solo tramos específicos.</li>
<li class="mb-2">El bloqueo no afecta las citas ya existentes.</li>
<li class="mb-2">Los clientes no podrán ver estos horarios.</li>
</ul>
</div>
</div>
<div class="card-admin">
<div class="card-header">
<i class="fas fa-calendar-alt me-2"></i>Horario de Atención
</div>
<div class="card-body">
<ul class="text-muted mb-0">
<li>Lunes a Sábado: <strong>9:00 AM - 7:00 PM</strong></li>
<li>Último turno: <strong>6:00 PM</strong></li>
<li>Duración cita: <strong>60 minutos</strong></li>
</ul>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
const hoy = new Date().toISOString().split('T')[0];
document.getElementById('fecha').setAttribute('min', hoy);
function bloquearDiaCompleto() {
document.getElementById('hora_inicio').value = '09:00:00';
document.getElementById('hora_fin').value = '19:00:00';
document.getElementById('motivo').value = 'Día completo bloqueado';
}
function bloquearManana() {
document.getElementById('hora_inicio').value = '09:00:00';
document.getElementById('hora_fin').value = '12:00:00';
document.getElementById('motivo').value = 'Mañana bloqueada';
}
function bloquearTarde() {
document.getElementById('hora_inicio').value = '12:00:00';
document.getElementById('hora_fin').value = '19:00:00';
document.getElementById('motivo').value = 'Tarde bloqueada';
}
</script>
@endpush

View File

@@ -0,0 +1,154 @@
@extends('admin.layouts.master')
@section('title', 'Editar Bloqueo - Lash Vanshy')
@section('page-title', 'Editar Bloqueo')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('admin.horarios.index') }}">Horarios Bloqueados</a></li>
<li class="breadcrumb-item active" aria-current="page">Editar Bloqueo</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<div class="card-admin">
<div class="card-header">
<i class="fas fa-edit me-2"></i>Editar Bloqueo
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.horarios.update', $bloqueado) }}">
@csrf
@method('PUT')
<div class="row">
<!-- Fecha -->
<div class="col-md-6 mb-3">
<label for="fecha" class="form-label">
Fecha <span class="text-danger">*</span>
</label>
<input type="date"
class="form-control @error('fecha') is-invalid @enderror"
id="fecha"
name="fecha"
value="{{ old('fecha', $bloqueado->fecha->format('Y-m-d')) }}"
required>
@error('fecha')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="row">
<!-- Hora Inicio -->
<div class="col-md-6 mb-3">
<label for="hora_inicio" class="form-label">
Hora de Inicio <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_inicio') is-invalid @enderror"
id="hora_inicio"
name="hora_inicio"
required>
@for($hora = 9; $hora < 19; $hora++)
<option value="{{ sprintf('%02d:00:00', $hora) }}"
{{ old('hora_inicio', $bloqueado->hora_inicio) == sprintf('%02d:00:00', $hora) ? 'selected' : '' }}>
{{ $hora <= 12 ? sprintf('%02d:00', $hora) : sprintf('%02d', $hora - 12) }}:00 {{ $hora >= 12 ? 'PM' : 'AM' }}
</option>
@endfor
</select>
@error('hora_inicio')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<!-- Hora Fin -->
<div class="col-md-6 mb-3">
<label for="hora_fin" class="form-label">
Hora de Fin <span class="text-danger">*</span>
</label>
<select class="form-select @error('hora_fin') is-invalid @enderror"
id="hora_fin"
name="hora_fin"
required>
@for($hora = 9; $hora < 19; $hora++)
<option value="{{ sprintf('%02d:00:00', $hora) }}"
{{ old('hora_fin', $bloqueado->hora_fin) == sprintf('%02d:00:00', $hora) ? 'selected' : '' }}>
{{ $hora <= 12 ? sprintf('%02d:00', $hora) : sprintf('%02d', $hora - 12) }}:00 {{ $hora >= 12 ? 'PM' : 'AM' }}
</option>
@endfor
</select>
@error('hora_fin')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<!-- Motivo -->
<div class="mb-3">
<label for="motivo" class="form-label">Motivo</label>
<input type="text"
class="form-control @error('motivo') is-invalid @enderror"
id="motivo"
name="motivo"
value="{{ old('motivo', $bloqueado->motivo) }}"
placeholder="Ej: Vacaciones, Mantenimiento...">
@error('motivo')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="d-flex justify-content-between mt-4">
<a href="{{ route('admin.horarios.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card-admin mb-3">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>Información del Bloqueo
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Creado:</dt>
<dd class="col-sm-7">{{ $bloqueado->created_at->format('d/m/Y H:i') }}</dd>
<dt class="col-sm-5">Actualizado:</dt>
<dd class="col-sm-7">{{ $bloqueado->updated_at->format('d/m/Y H:i') }}</dd>
</dl>
</div>
</div>
<div class="card-admin">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Eliminar Bloqueo
</div>
<div class="card-body">
<p class="text-muted mb-3">
¿Estás seguro de desbloquear este horario? Los clientes podrán agendar citas en este espacio.
</p>
<form action="{{ route('admin.horarios.destroy', $bloqueado) }}" method="POST"
onsubmit="return confirm('¿Estás seguro de eliminar este bloqueo?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger-admin w-100">
<i class="fas fa-unlock me-2"></i>Desbloquear
</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,140 @@
@extends('admin.layouts.master')
@section('title', 'Horarios Bloqueados - Lash Vanshy')
@section('page-title', 'Horarios Bloqueados')
@section('content')
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item active" aria-current="page">Horarios Bloqueados</li>
</ol>
</nav>
<!-- Actions & Filters -->
<div class="card-admin mb-4">
<div class="card-body">
<div class="filters-bar">
<form method="GET" class="d-flex gap-3 flex-wrap align-items-center">
<div class="search-box">
<i class="fas fa-calendar"></i>
<input type="date" name="fecha" class="form-control"
value="{{ request('fecha') }}">
</div>
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-filter me-2"></i>Filtrar
</button>
@if(request('fecha'))
<a href="{{ route('admin.horarios.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-times me-2"></i>Limpiar
</a>
@endif
</form>
<div class="d-flex gap-2 ms-auto">
<a href="{{ route('admin.horarios.create') }}" class="btn btn-primary-admin">
<i class="fas fa-plus me-2"></i>Bloquear Horario
</a>
<a href="{{ route('admin.citas.index') }}" class="btn btn-secondary-admin">
<i class="fas fa-arrow-left me-2"></i>Volver a Citas
</a>
</div>
</div>
</div>
</div>
<!-- Horarios Table -->
<div class="card-admin">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-clock me-2"></i>Horarios Bloqueados
</span>
<span class="text-muted small">
Total: {{ $bloqueados->total() }} bloqueos
</span>
</div>
<div class="card-body p-0">
@if($bloqueados->isEmpty())
<div class="empty-state">
<i class="fas fa-calendar-check"></i>
<h4>No hay horarios bloqueados</h4>
<p>No se encontraron horarios bloqueados.</p>
<a href="{{ route('admin.horarios.create') }}" class="btn btn-primary-admin mt-3">
<i class="fas fa-plus me-2"></i>Bloquear Primer Horario
</a>
</div>
@else
<div class="table-responsive">
<table class="table-admin">
<thead>
<tr>
<th>Fecha</th>
<th>Hora Inicio</th>
<th>Hora Fin</th>
<th>Motivo</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@foreach($bloqueados as $bloqueado)
<tr>
<td>
<span class="fw-bold">
<i class="fas fa-calendar-alt me-1 text-primary"></i>
{{ \Carbon\Carbon::parse($bloqueado->fecha)->format('d/m/Y') }}
</span>
<span class="text-muted small d-block">
{{ \Carbon\Carbon::parse($bloqueado->fecha)->format('l') }}
</span>
</td>
<td>{{ \Carbon\Carbon::parse($bloqueado->hora_inicio)->format('h:i A') }}</td>
<td>{{ \Carbon\Carbon::parse($bloqueado->hora_fin)->format('h:i A') }}</td>
<td>
@if($bloqueado->motivo)
<span class="text-muted">{{ $bloqueado->motivo }}</span>
@else
<span class="text-muted">-</span>
@endif
</td>
<td>
<div class="actions">
<a href="{{ route('admin.horarios.edit', $bloqueado) }}"
class="btn btn-sm btn-secondary-admin"
title="Editar">
<i class="fas fa-edit"></i>
</a>
<form action="{{ route('admin.horarios.destroy', $bloqueado) }}" method="POST"
onsubmit="return confirm('¿Estás seguro de desbloquear este horario?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger-admin" title="Eliminar">
<i class="fas fa-unlock"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination-wrapper p-3">
{{ $bloqueados->withQueryString()->links() }}
</div>
@endif
</div>
</div>
<!-- Info Box -->
<div class="alert alert-info mt-4">
<i class="fas fa-info-circle me-2"></i>
<strong>Nota:</strong> Los horarios bloqueados impedirán que se agenden citas en esos horarios.
Utilice esta función para bloquear días completos o tramos específicos (ej: vacaciones, mantenimiento).
</div>
@endsection

View File

@@ -75,6 +75,31 @@
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-calendar-alt"></i>
<span>Citas</span>
</a>
<ul class="dropdown-menu">
<li>
<a href="{{ route('admin.citas.index') }}" class="dropdown-item {{ request()->routeIs('admin.citas.index') ? 'active' : '' }}">
<i class="fas fa-list me-2"></i>Lista de Citas
</a>
</li>
<li>
<a href="{{ route('admin.citas.calendario') }}" class="dropdown-item {{ request()->routeIs('admin.citas.calendario') ? 'active' : '' }}">
<i class="fas fa-calendar me-2"></i>Calendario
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a href="{{ route('admin.horarios.index') }}" class="dropdown-item {{ request()->routeIs('admin.horarios.*') ? 'active' : '' }}">
<i class="fas fa-clock me-2"></i>Horarios Bloqueados
</a>
</li>
</ul>
</li>
@if(Auth::guard('admin')->user()->rol === 'super_admin')
<li class="nav-item">
<a href="{{ route('admin.configuracion.index') }}"

View File

@@ -70,15 +70,26 @@
<i class="fas fa-arrow-left me-2"></i>Volver
</a>
<div class="d-flex gap-2">
@if(!isset($yaTieneCita) || !$yaTieneCita)
<a href="{{ route('admin.citas.create', ['mensaje_id' => $mensaje->id]) }}" class="btn btn-primary-admin">
<i class="fas fa-calendar-plus me-2"></i>Agendar Cita
</a>
@else
<span class="badge-admin bg-success">
<i class="fas fa-check-circle me-1"></i>Ya tiene cita
</span>
@endif
@if(!$mensaje->leido)
<form action="{{ route('admin.mensajes.leido', $mensaje) }}" method="POST">
@csrf
@method('PATCH')
<button type="submit" class="btn btn-primary-admin">
<i class="fas fa-check me-2"></i>Marcar como leído
<button type="submit" class="btn btn-secondary-admin">
<i class="fas fa-check me-2"></i>Marcar leído
</button>
</form>
@endif
<form action="{{ route('admin.mensajes.destroy', $mensaje) }}" method="POST" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este mensaje?')">
@csrf
@method('DELETE')

View File

@@ -2,9 +2,11 @@
use App\Http\Controllers\Admin\AdminUserController;
use App\Http\Controllers\Admin\AuthController;
use App\Http\Controllers\Admin\CitaController;
use App\Http\Controllers\Admin\ConfiguracionController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\GaleriaController;
use App\Http\Controllers\Admin\HorarioBloqueadoController;
use App\Http\Controllers\Admin\MensajeController;
use App\Http\Controllers\Admin\ProductoController;
use Illuminate\Support\Facades\Route;
@@ -76,4 +78,36 @@ Route::middleware(['admin.auth', 'security.headers'])->group(function () {
Route::get('/', [ConfiguracionController::class, 'index'])->name('index');
Route::put('/', [ConfiguracionController::class, 'update'])->name('update');
});
// 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, 'createFromMensaje'])->name('create-from-mensaje');
Route::post('/', [CitaController::class, 'store'])->name('store');
Route::get('/calendario', [CitaController::class, 'calendario'])->name('calendario');
Route::get('/{cita}', [CitaController::class, 'show'])->name('show');
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 Citas
Route::get('/citas/disponibles', [CitaController::class, 'getHorariosDisponibles'])->name('admin.citas.disponibles');
Route::get('/citas/por-fecha', [CitaController::class, 'getCitasPorFecha'])->name('admin.citas.por-fecha');
// Horarios Bloqueados
Route::prefix('horarios')->name('admin.horarios.')->group(function () {
Route::get('/', [HorarioBloqueadoController::class, 'index'])->name('index');
Route::get('/create', [HorarioBloqueadoController::class, 'create'])->name('create');
Route::post('/', [HorarioBloqueadoController::class, 'store'])->name('store');
Route::get('/{bloqueado}/edit', [HorarioBloqueadoController::class, 'edit'])->name('edit');
Route::put('/{bloqueado}', [HorarioBloqueadoController::class, 'update'])->name('update');
Route::delete('/{bloqueado}', [HorarioBloqueadoController::class, 'destroy'])->name('destroy');
});
// API de Horarios
Route::get('/horarios/por-fecha', [HorarioBloqueadoController::class, 'getHorariosPorFecha'])->name('admin.horarios.por-fecha');
Route::get('/horarios/verificar', [HorarioBloqueadoController::class, 'verificar'])->name('admin.horarios.verificar');
});