diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb564ef..0a3b9ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,10 +42,17 @@ const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href const LoginRoute: FC = () => { const { user, loading } = useAuth(); if (loading) return ; - if (user) return ; + if (user) return ; return ; }; +// Pantalla inicial por rol: el médico aterriza directo en su Agenda +const HomeRoute: FC = () => { + const { user } = useAuth(); + if (user?.role === 'medico') return ; + return ; +}; + const App: FC = () => { return ( @@ -54,7 +61,7 @@ const App: FC = () => { } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index c6783e4..c44f3ba 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -19,6 +19,7 @@ import { } from '../components/ui'; import { useSearchParams } from 'react-router-dom'; import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo'; +import { useAuth } from '../lib/auth'; const stateOptions = [ { value: '', label: 'Todos los estados' }, @@ -28,8 +29,24 @@ const stateOptions = [ { value: 'cancelled', label: 'Cancelada' }, ]; +// Opciones de hora en bloques de 15 min dentro del horario de agenda (8:00-19:00) +const QUARTER_TIME_OPTIONS = (() => { + const opts = [{ value: '', label: 'Seleccionar hora...' }]; + for (let h = 8; h < 19; h++) { + for (const m of [0, 15, 30, 45]) { + const v = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + opts.push({ value: v, label: v }); + } + } + return opts; +})(); + const Agenda: FC = () => { const [searchParams] = useSearchParams(); + const { user } = useAuth(); + // Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor) + const isMedico = user?.role === 'medico'; + const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null; const [appointments, setAppointments] = useState([]); const [patients, setPatients] = useState([]); const [services, setServices] = useState([]); @@ -40,7 +57,7 @@ const Agenda: FC = () => { const [error, setError] = useState(null); const [date, setDate] = useState(() => searchParams.get('date') || new Date().toISOString().split('T')[0]); const [stateFilter, setStateFilter] = useState(''); - const [doctorFilter, setDoctorFilter] = useState(''); + const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : '')); const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores'); const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>(''); const [detailApt, setDetailApt] = useState(null); @@ -67,6 +84,16 @@ const Agenda: FC = () => { sessions: 1, }); const [patientSearch, setPatientSearch] = useState(''); + // Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto) + const [quickMode, setQuickMode] = useState(false); + const [quickForm, setQuickForm] = useState({ + name: '', + phone: '', + doctor_id: '', + date: new Date().toISOString().split('T')[0], + time: '', + notes: '', + }); const [pkgUpdating, setPkgUpdating] = useState(null); const [expressOpen, setExpressOpen] = useState(false); const [expressSubmitting, setExpressSubmitting] = useState(false); @@ -121,26 +148,31 @@ const Agenda: FC = () => { const countCheckin = filteredAppointments.filter(isCheckinDue).length; const countNoShow = filteredAppointments.filter(isNoShowDue).length; - // ---- Vista por médico (grid doctores x horas) ---- - const GRID_BASE_START = 9; // horario clínica: 9:00 - const GRID_BASE_END = 18; // horario clínica: 18:00 + // ---- Vista por médico (grid doctores x bloques de 15 min) ---- + const GRID_START = 8; // horario clínica: 8:00 + const GRID_END = 19; // horario clínica: 19:00 (último bloque inicia 18:45) + const SLOT_MIN = 15; + const SLOTS_PER_HOUR = 60 / SLOT_MIN; - const aptHour = (t: string): number | null => { + // Bloque de 15 min al que pertenece una hora "HH:MM" (null si fuera de 8:00-19:00) + const aptSlot = (t: string): number | null => { const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim()); if (!m) return null; const h = parseInt(m[1], 10); - return Number.isNaN(h) ? null : h; + const mins = m[2] ? parseInt(m[2], 10) : 0; + if (Number.isNaN(h) || Number.isNaN(mins)) return null; + const f = h + mins / 60; + if (f < GRID_START || f >= GRID_END) return null; + return GRID_START + Math.floor((f - GRID_START) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR; }; // Las canceladas no ocupan horario: el hueco queda libre const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled'); - const bookedHours = gridAppointments - .map((a) => aptHour(a.time)) - .filter((h): h is number => h !== null); - const gridStart = bookedHours.length ? Math.min(GRID_BASE_START, ...bookedHours) : GRID_BASE_START; - const gridEnd = bookedHours.length ? Math.max(GRID_BASE_END, ...bookedHours) : GRID_BASE_END; - const gridHours = Array.from({ length: gridEnd - gridStart + 1 }, (_, i) => gridStart + i); + const gridSlots: number[] = Array.from( + { length: (GRID_END - GRID_START) * SLOTS_PER_HOUR }, + (_, i) => GRID_START + i / SLOTS_PER_HOUR + ); const gridDoctors = doctorFilter ? doctors.filter((d) => String(d.id) === doctorFilter) @@ -151,34 +183,35 @@ const Agenda: FC = () => { ...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []), ]; - const gridCellAppointments = (doctorId: number | null, hour: number) => + const gridCellAppointments = (doctorId: number | null, slot: number) => gridAppointments.filter((a) => - (doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptHour(a.time) === hour); + (doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptSlot(a.time) === slot); const gridDoctorCount = (doctorId: number | null) => gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length; - // Bloqueo que cubre una celda (médico × hora) - const bloqueoEnCelda = (doctorId: number | null, hour: number) => + // Bloqueo que cubre una celda (médico × bloque de 15 min) + const bloqueoEnCelda = (doctorId: number | null, slot: number) => doctorId - ? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (hour >= b.time_from && hour < b.time_to))) + ? bloqueos.find((b) => b.doctor_id === doctorId && + (b.all_day || (slot < b.time_to && slot + SLOT_MIN / 60 > b.time_from))) : undefined; // Drag & drop para mover citas (solo pendientes/confirmadas) const DRAGGABLE_STATES = ['pending', 'confirmed']; - const handleDrop = async (doctorId: number | null, hour: number) => { + const handleDrop = async (doctorId: number | null, slot: number) => { if (!dragApt) return; const apt = dragApt; setDragApt(null); setDropTarget(null); - if ((apt.doctor_id ?? null) === doctorId && aptHour(apt.time) === hour) return; + if ((apt.doctor_id ?? null) === doctorId && aptSlot(apt.time) === slot) return; const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar'; - const horaStr = `${String(hour).padStart(2, '0')}:00`; + const horaStr = horaFloatStr(slot); if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return; try { await odooApi.updateAppointment(apt.id, { doctor_id: doctorId ?? null, - time: hour as unknown as string, + time: slot as unknown as string, }); toast.success('Cita movida'); await load(); @@ -518,10 +551,52 @@ const Agenda: FC = () => { } }; - const doctorOptions = [ - { value: '', label: 'Todos los médicos' }, - ...doctors.map((d) => ({ value: String(d.id), label: d.name })), - ]; + const openCreate = () => { + setQuickMode(false); + if (medicoEmployeeId) { + setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) })); + } + setQuickForm({ + name: '', + phone: '', + doctor_id: medicoEmployeeId ? String(medicoEmployeeId) : doctorFilter, + date, + time: '', + notes: '', + }); + setCreateOpen(true); + }; + + const submitQuick = async () => { + if (!quickForm.name.trim() || !quickForm.phone.trim()) { + toast.error('Nombre y teléfono son obligatorios'); + return; + } + if (!quickForm.date || !quickForm.time) { + toast.error('Selecciona fecha y hora'); + return; + } + try { + setSubmitting(true); + const res = await odooApi.createQuickAppointment({ + name: quickForm.name.trim(), + phone: quickForm.phone.trim(), + date: quickForm.date, + time: parseTimeToFloat(quickForm.time) ?? 0, + doctor_id: quickForm.doctor_id ? parseInt(quickForm.doctor_id, 10) : undefined, + notes: quickForm.notes || undefined, + }); + toast.success(res.patient_created ? 'Paciente y cita rápida creados' : 'Cita rápida creada (paciente existente)'); + setCreateOpen(false); + await load(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al crear la cita rápida'); + console.error(err); + } finally { + setSubmitting(false); + } + }; const patientOptions = [ { value: '', label: 'Seleccionar paciente...' }, @@ -655,7 +730,7 @@ const Agenda: FC = () => { Valoración express - @@ -678,13 +753,6 @@ const Agenda: FC = () => { onChange={(e) => setStateFilter(e.target.value)} className="sm:max-w-[180px]" /> - { footer={ <> - + } >
+ + {quickMode ? ( + <> +

+ Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos. +

+ setQuickForm({ ...quickForm, name: e.target.value })} + /> + setQuickForm({ ...quickForm, phone: e.target.value })} + /> + setQuickForm({ ...quickForm, date: e.target.value })} + /> +