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