Agenda: horario 8-19h, bloques de 15 min, cita rapida, vista restringida para rol medico y chips de pacientes por doctor

This commit is contained in:
2026-08-28 07:35:46 +00:00
parent f9fc66b343
commit 29225e848a
5 changed files with 446 additions and 81 deletions

View File

@@ -42,10 +42,17 @@ const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href
const LoginRoute: FC = () => {
const { user, loading } = useAuth();
if (loading) return <FullScreenLoader />;
if (user) return <Navigate to="/" replace />;
if (user) return <Navigate to={user.role === 'medico' ? '/agenda' : '/'} replace />;
return <Login />;
};
// Pantalla inicial por rol: el médico aterriza directo en su Agenda
const HomeRoute: FC = () => {
const { user } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
return <Dashboard />;
};
const App: FC = () => {
return (
<AuthProvider>
@@ -54,7 +61,7 @@ const App: FC = () => {
<ToastProvider />
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/" element={<RequireAuth href="/"><Dashboard /></RequireAuth>} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />

View File

@@ -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<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -40,7 +57,7 @@ const Agenda: FC = () => {
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => 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<Appointment | null>(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<number | null>(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 = () => {
<Sparkles size={16} className="mr-2" />
Valoración express
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nueva cita
</Button>
@@ -678,13 +753,6 @@ const Agenda: FC = () => {
onChange={(e) => setStateFilter(e.target.value)}
className="sm:max-w-[180px]"
/>
<Select
label="Médico"
options={doctorOptions}
value={doctorFilter}
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
<div className="flex gap-1 sm:ml-auto">
<Button
variant={viewMode === 'lista' ? 'primary' : 'outline'}
@@ -716,6 +784,65 @@ const Agenda: FC = () => {
</div>
</div>
{/* Filtro de médicos: pill por médico con chip de pacientes asignados */}
<div className="mb-4 sm:mb-5">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Médico</p>
{isMedico ? (
medicoEmployeeId ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border bg-theme-accent text-theme-inverse border-theme-accent"
title="Tu agenda: solo ves tus propias citas">
{user?.employee_name || 'Mi agenda'}
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] bg-theme-surface/30 text-theme-inverse">
{doctors.find((d) => d.id === medicoEmployeeId)?.patient_count ?? 0}
</span>
</span>
) : (
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 inline-block">
Tu usuario no está vinculado a un médico (hr.employee). Pide a un administrador que vincule tu usuario.
</p>
)
) : (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDoctorFilter('')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
!doctorFilter
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Todos
</button>
{doctors.map((d) => {
const active = doctorFilter === String(d.id);
return (
<button
key={d.id}
type="button"
onClick={() => setDoctorFilter(active ? '' : String(d.id))}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{d.name}
<span
title="Pacientes asignados"
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
active ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}
>
{d.patient_count ?? 0}
</span>
</button>
);
})}
</div>
)}
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{viewMode !== 'lista' && (
@@ -805,34 +932,34 @@ const Agenda: FC = () => {
</tr>
</thead>
<tbody>
{gridHours.map((hour) => (
<tr key={hour}>
<td className="sticky left-0 z-10 bg-theme-surface p-3 text-xs font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
{gridSlots.map((slot) => (
<tr key={slot}>
<td className="sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
{horaFloatStr(slot)}
</td>
{gridColumns.map((col) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
const cell = gridCellAppointments(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
return (
<td
key={col.key}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-xl border border-theme-border py-3.5 text-center text-[11px] font-medium text-theme-muted"
className="rounded-lg border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={12} className="inline mr-1" />
<Ban size={11} className="inline mr-1" />
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
</div>
) : cell.length === 0 ? (
<div className={`rounded-xl border py-3.5 text-center text-[11px] font-medium transition-colors ${
<div className={`rounded-lg border py-1.5 text-center text-[10px] font-medium transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
@@ -845,7 +972,7 @@ const Agenda: FC = () => {
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs shadow-card transition-shadow hover:shadow-soft ${
className={`mb-1 rounded-lg border-l-4 px-2 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
@@ -883,9 +1010,9 @@ const Agenda: FC = () => {
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{gridHours.map((hour) => (
<th key={hour} className="min-w-[96px] p-2.5 text-center text-xs font-medium text-theme-muted border-b border-l border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
{gridSlots.map((slot) => (
<th key={slot} className="min-w-[52px] p-1 text-center text-[10px] font-medium text-theme-muted border-b border-l border-theme-border">
{horaFloatStr(slot)}
</th>
))}
</tr>
@@ -908,34 +1035,34 @@ const Agenda: FC = () => {
</div>
</div>
</td>
{gridHours.map((hour) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
{gridSlots.map((slot) => {
const cell = gridCellAppointments(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
return (
<td
key={hour}
key={slot}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-0.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-lg border border-theme-border py-2 text-center text-[10px] font-medium text-theme-muted"
className="rounded border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={11} className="inline" />
<Ban size={10} className="inline" />
</div>
) : cell.length === 0 ? (
<div
className={`rounded-lg border py-2 transition-colors ${
className={`rounded border py-1.5 transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700'
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
}`}
title="Libre"
title={`Libre ${horaFloatStr(slot)}`}
/>
) : (
cell.map((apt) => (
@@ -943,7 +1070,7 @@ const Agenda: FC = () => {
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1 rounded-lg border-l-2 px-1.5 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
@@ -970,7 +1097,7 @@ const Agenda: FC = () => {
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
actionLabel="Nueva cita"
onAction={() => setCreateOpen(true)}
onAction={openCreate}
/>
) : (
<>
@@ -1045,6 +1172,7 @@ const Agenda: FC = () => {
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={bloqueoForm.doctor_id}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<Input
label="Fecha *"
@@ -1127,11 +1255,68 @@ const Agenda: FC = () => {
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
<Button onClick={quickMode ? submitQuick : createAppointment} loading={submitting}>
{quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
</Button>
</>
}
>
<div className="space-y-4">
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={quickMode}
onChange={(e) => setQuickMode(e.target.checked)}
className="rounded border-theme-border-strong"
/>
Cita rápida (solo nombre y teléfono)
</label>
{quickMode ? (
<>
<p className="text-xs text-theme-muted">
Si el teléfono ya existe, se reutiliza ese paciente; si no, se crea con datos mínimos.
</p>
<Input
label="Nombre *"
placeholder="Nombre del paciente"
value={quickForm.name}
onChange={(e) => setQuickForm({ ...quickForm, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={quickForm.phone}
onChange={(e) => setQuickForm({ ...quickForm, phone: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={quickForm.doctor_id}
onChange={(e) => setQuickForm({ ...quickForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={quickForm.date}
onChange={(e) => setQuickForm({ ...quickForm, date: e.target.value })}
/>
<Select
label="Hora *"
options={QUARTER_TIME_OPTIONS}
value={quickForm.time}
onChange={(e) => setQuickForm({ ...quickForm, time: e.target.value })}
/>
</div>
<TextArea
label="Notas"
value={quickForm.notes}
onChange={(e) => setQuickForm({ ...quickForm, notes: e.target.value })}
/>
</>
) : (
<>
<Input
label="Buscar paciente"
placeholder="Escribe nombre o teléfono..."
@@ -1155,6 +1340,7 @@ const Agenda: FC = () => {
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.doctor_id}
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
disabled={isMedico}
/>
<div className="grid grid-cols-2 gap-4">
<Input
@@ -1197,6 +1383,8 @@ const Agenda: FC = () => {
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</>
)}
</div>
</Modal>
@@ -1256,6 +1444,7 @@ const Agenda: FC = () => {
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={expressForm.doctor_id}
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<div className="grid grid-cols-2 gap-4">
<Input
@@ -1267,6 +1456,9 @@ const Agenda: FC = () => {
<Input
label="Hora *"
type="time"
min="08:00"
max="19:00"
step={900}
value={expressForm.time}
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
/>
@@ -1375,6 +1567,9 @@ const Agenda: FC = () => {
<Input
label="Hora *"
type="time"
min="08:00"
max="19:00"
step={900}
value={editForm.time}
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
/>
@@ -1384,6 +1579,7 @@ const Agenda: FC = () => {
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={editForm.doctor_id}
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
disabled={isMedico}
/>
<TextArea
label="Notas"

View File

@@ -532,6 +532,9 @@ export interface FrontendUser {
last_login?: string | null;
active?: boolean;
allowed_menus?: string[] | null;
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
employee_id?: number | null;
employee_name?: string | null;
}
export interface Receta {
@@ -866,6 +869,11 @@ export const odooApi = {
return data;
},
async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data;
},
async updateAppointment(id: number, appointment: Partial<Appointment>): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`, appointment);
return data;

View File

@@ -256,19 +256,20 @@ class SkeenCita(models.Model):
"""Retorna slots disponibles para una fecha y servicio"""
servicio = self.env['skeen.servicio'].browse(servicio_id)
duration = servicio.duration_min if servicio else 30
# Horario de la clínica
start_time = 9.0 # 9:00 AM
end_time = 18.0 # 6:00 PM
# Horario de la clínica: 8:00 AM a 7:00 PM
start_time = 8.0 # 8:00 AM
end_time = 19.0 # 7:00 PM
# Generar slots
slots = []
current = start_time
while current + (duration / 60.0) <= end_time:
while current + (duration / 60.0) <= end_time + 1e-9:
# Verificar si está ocupado
slot_start = datetime.combine(date, datetime.min.time().replace(hour=int(current), minute=int((current % 1) * 60)))
minutes = int(round((current % 1) * 60))
slot_start = datetime.combine(date, datetime.min.time().replace(hour=int(current), minute=minutes))
slot_end = slot_start + timedelta(minutes=duration)
domain = [
('date', '=', date),
('state', 'not in', ('cancelled', 'no_show')),
@@ -278,8 +279,8 @@ class SkeenCita(models.Model):
if not self.search(domain):
slots.append({
'time': current,
'time_str': f"{int(current):02d}:{int((current % 1) * 60):02d}",
'time_str': f"{int(current):02d}:{minutes:02d}",
})
current += 0.5 # Slots cada 30 min
current += 0.25 # Slots cada 15 min
return slots

View File

@@ -4,6 +4,7 @@ from odoo import http
from odoo.http import request, Response
import base64
import json
import re
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
@@ -35,6 +36,43 @@ def _parse_json_body():
class SkeenFrontendController(http.Controller):
"""Controladores API públicos para el Frontend React de SKEEN"""
# Horario de agenda de la clínica (hora de inicio permitida de una cita)
AGENDA_START = 8.0 # 8:00 AM
AGENDA_END = 19.0 # 7:00 PM
def _valid_agenda_time(self, value):
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
try:
t = float(value)
except (TypeError, ValueError):
return False
return self.AGENDA_START <= t < self.AGENDA_END
def _linked_employee(self, user):
"""hr.employee vinculado a un usuario frontend (rol medico).
Vía principal: res.users con el mismo login -> employee_ids.
Fallback: empleado cuyo work_email coincide con el login o cuyo
nombre coincide con el nombre del usuario frontend.
"""
Emp = request.env['hr.employee'].sudo()
ru = request.env['res.users'].sudo().search([('login', '=', user.login)], limit=1)
emp = ru.employee_ids[:1] if ru else Emp.browse()
if not emp and user.login:
emp = Emp.search([('work_email', '=ilike', user.login)], limit=1)
if not emp and user.name:
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
return emp
def _frontend_user_dict(self, user):
"""to_public_dict + datos del empleado vinculado (rol medico)."""
d = user.to_public_dict()
if user.role == 'medico':
emp = self._linked_employee(user)
d['employee_id'] = emp.id if emp else None
d['employee_name'] = emp.name if emp else None
return d
# ============================================================
# Health
# ============================================================
@@ -61,7 +99,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Credenciales inválidas'}, 401)
user.write({'last_login': fields.Datetime.now()})
token = sign_token(request.env, user)
return json_response({'status': 'success', 'token': token, 'user': user.to_public_dict()})
return json_response({'status': 'success', 'token': token, 'user': self._frontend_user_dict(user)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -69,7 +107,7 @@ class SkeenFrontendController(http.Controller):
@require_role('lectura')
def auth_me(self, **kw):
try:
return json_response({'status': 'success', 'user': request.frontend_user.to_public_dict()})
return json_response({'status': 'success', 'user': self._frontend_user_dict(request.frontend_user)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -274,9 +312,30 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_appointments(self, **kw):
"""Listar citas (opcionalmente filtradas por fecha, estado, paciente)"""
"""Listar citas (opcionalmente filtradas por fecha, estado, paciente)
Rol 'medico': solo ve las citas asignadas a su hr.employee vinculado
(filtro del lado del servidor, ignora cualquier doctor_id recibido).
"""
try:
domain = []
user = request.frontend_user
if user and user.role == 'medico':
emp = self._linked_employee(user)
if not emp:
# Sin empleado vinculado: agenda vacía (el frontend muestra aviso)
return json_response({
'status': 'success',
'appointments': [],
'bloqueos': [],
'total': 0,
'page': 1,
'page_size': int(kw.get('page_size', 50) or 50),
'total_pages': 0,
'medico_sin_employee': True,
})
domain = [('doctor_id', '=', emp.id)]
else:
domain = []
if kw.get('date'):
domain.append(('date', '=', kw.get('date')))
if kw.get('date_from'):
@@ -287,7 +346,7 @@ class SkeenFrontendController(http.Controller):
domain.append(('state', '=', kw.get('state')))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
if kw.get('doctor_id'):
if kw.get('doctor_id') and not (user and user.role == 'medico'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('search'):
s = kw.get('search')
@@ -344,6 +403,9 @@ class SkeenFrontendController(http.Controller):
}
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
created = []
# Para paquetes, generamos citas separadas con fechas consecutivas
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
@@ -365,6 +427,94 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_quick_appointment(self, **kw):
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
Busca el paciente por teléfono y lo reutiliza si existe; si no, lo crea
con datos mínimos. El servicio usa uno por defecto (primera 'consulta'
activa, o el primer servicio activo) salvo que se indique servicio_id.
"""
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
phone = (data.get('phone') or '').strip()
if not name or not phone:
return json_response({'status': 'error', 'message': 'Nombre y teléfono son obligatorios'}, 400)
apt_date = data.get('date') or date.today().strftime('%Y-%m-%d')
apt_time = data.get('time')
if apt_time is None or not self._valid_agenda_time(apt_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
Partner = request.env['res.partner'].sudo()
digits = re.sub(r'\D', '', phone)
patient = None
if len(digits) >= 7:
# Comparación sobre dígitos normalizados (ignora espacios, +52, etc.):
# coinciden si los últimos N dígitos (N = longitud del más corto) son iguales
match_id = None
candidates = Partner.search_read(
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
['phone', 'mobile'], limit=10000)
for c in candidates:
for f in ('phone', 'mobile'):
c_digits = re.sub(r'\D', '', c.get(f) or '')
n = min(len(c_digits), len(digits))
if n >= 7 and c_digits[-n:] == digits[-n:]:
match_id = c['id']
break
if match_id:
break
if match_id:
patient = Partner.browse(match_id)
if not patient:
patient = Partner.search([
('is_patient', '=', True), ('phone', '=', phone),
], limit=1)
patient_created = False
if not patient:
patient = Partner.create({
'name': name,
'phone': phone,
'is_patient': True,
'source': data.get('source', 'walkin'),
})
patient_created = True
Servicio = request.env['skeen.servicio'].sudo()
servicio = Servicio.browse(int(data.get('servicio_id') or 0))
if not servicio.exists():
servicio = Servicio.search([('category', '=', 'consulta'), ('active', '=', True)],
order='sequence, id', limit=1)
if not servicio:
servicio = Servicio.search([('active', '=', True)], order='sequence, id', limit=1)
if not servicio:
return json_response({'status': 'error', 'message': 'No hay servicios activos para agendar'}, 400)
cita = request.env['skeen.cita'].sudo().create({
'partner_id': patient.id,
'servicio_id': servicio.id,
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'date': apt_date,
'time': float(apt_time),
'branch': data.get('branch', 'rosarito'),
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
})
return json_response({
'status': 'success',
'appointment': self._appointment_to_dict(cita),
'patient': self._patient_to_dict(patient),
'patient_created': patient_created,
}, 201)
except Exception as e:
# Rollback explícito: evita persistir paciente/cita con validación rechazada
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_appointment(self, appointment_id, **kw):
@@ -386,6 +536,9 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
cita.write(vals)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e: