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:
@@ -42,10 +42,17 @@ const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href
|
|||||||
const LoginRoute: FC = () => {
|
const LoginRoute: FC = () => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
if (loading) return <FullScreenLoader />;
|
if (loading) return <FullScreenLoader />;
|
||||||
if (user) return <Navigate to="/" replace />;
|
if (user) return <Navigate to={user.role === 'medico' ? '/agenda' : '/'} replace />;
|
||||||
return <Login />;
|
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 = () => {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
@@ -54,7 +61,7 @@ const App: FC = () => {
|
|||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRoute />} />
|
<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="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||||
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
||||||
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
|
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
|
||||||
const stateOptions = [
|
const stateOptions = [
|
||||||
{ value: '', label: 'Todos los estados' },
|
{ value: '', label: 'Todos los estados' },
|
||||||
@@ -28,8 +29,24 @@ const stateOptions = [
|
|||||||
{ value: 'cancelled', label: 'Cancelada' },
|
{ 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 Agenda: FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
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 [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
@@ -40,7 +57,7 @@ const Agenda: FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||||
const [stateFilter, setStateFilter] = useState('');
|
const [stateFilter, setStateFilter] = useState('');
|
||||||
const [doctorFilter, setDoctorFilter] = useState('');
|
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
|
||||||
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
||||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
|
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
|
||||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||||
@@ -67,6 +84,16 @@ const Agenda: FC = () => {
|
|||||||
sessions: 1,
|
sessions: 1,
|
||||||
});
|
});
|
||||||
const [patientSearch, setPatientSearch] = useState('');
|
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 [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
|
||||||
const [expressOpen, setExpressOpen] = useState(false);
|
const [expressOpen, setExpressOpen] = useState(false);
|
||||||
const [expressSubmitting, setExpressSubmitting] = useState(false);
|
const [expressSubmitting, setExpressSubmitting] = useState(false);
|
||||||
@@ -121,26 +148,31 @@ const Agenda: FC = () => {
|
|||||||
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
|
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
|
||||||
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
|
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
|
||||||
|
|
||||||
// ---- Vista por médico (grid doctores x horas) ----
|
// ---- Vista por médico (grid doctores x bloques de 15 min) ----
|
||||||
const GRID_BASE_START = 9; // horario clínica: 9:00
|
const GRID_START = 8; // horario clínica: 8:00
|
||||||
const GRID_BASE_END = 18; // horario clínica: 18: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());
|
const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const h = parseInt(m[1], 10);
|
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
|
// Las canceladas no ocupan horario: el hueco queda libre
|
||||||
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
|
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
|
||||||
|
|
||||||
const bookedHours = gridAppointments
|
const gridSlots: number[] = Array.from(
|
||||||
.map((a) => aptHour(a.time))
|
{ length: (GRID_END - GRID_START) * SLOTS_PER_HOUR },
|
||||||
.filter((h): h is number => h !== null);
|
(_, i) => GRID_START + i / SLOTS_PER_HOUR
|
||||||
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 gridDoctors = doctorFilter
|
const gridDoctors = doctorFilter
|
||||||
? doctors.filter((d) => String(d.id) === doctorFilter)
|
? doctors.filter((d) => String(d.id) === doctorFilter)
|
||||||
@@ -151,34 +183,35 @@ const Agenda: FC = () => {
|
|||||||
...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []),
|
...(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) =>
|
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) =>
|
const gridDoctorCount = (doctorId: number | null) =>
|
||||||
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
|
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
|
||||||
|
|
||||||
// Bloqueo que cubre una celda (médico × hora)
|
// Bloqueo que cubre una celda (médico × bloque de 15 min)
|
||||||
const bloqueoEnCelda = (doctorId: number | null, hour: number) =>
|
const bloqueoEnCelda = (doctorId: number | null, slot: number) =>
|
||||||
doctorId
|
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;
|
: undefined;
|
||||||
|
|
||||||
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
// Drag & drop para mover citas (solo pendientes/confirmadas)
|
||||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||||
const handleDrop = async (doctorId: number | null, hour: number) => {
|
const handleDrop = async (doctorId: number | null, slot: number) => {
|
||||||
if (!dragApt) return;
|
if (!dragApt) return;
|
||||||
const apt = dragApt;
|
const apt = dragApt;
|
||||||
setDragApt(null);
|
setDragApt(null);
|
||||||
setDropTarget(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 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;
|
if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return;
|
||||||
try {
|
try {
|
||||||
await odooApi.updateAppointment(apt.id, {
|
await odooApi.updateAppointment(apt.id, {
|
||||||
doctor_id: doctorId ?? null,
|
doctor_id: doctorId ?? null,
|
||||||
time: hour as unknown as string,
|
time: slot as unknown as string,
|
||||||
});
|
});
|
||||||
toast.success('Cita movida');
|
toast.success('Cita movida');
|
||||||
await load();
|
await load();
|
||||||
@@ -518,10 +551,52 @@ const Agenda: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doctorOptions = [
|
const openCreate = () => {
|
||||||
{ value: '', label: 'Todos los médicos' },
|
setQuickMode(false);
|
||||||
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
|
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 = [
|
const patientOptions = [
|
||||||
{ value: '', label: 'Seleccionar paciente...' },
|
{ value: '', label: 'Seleccionar paciente...' },
|
||||||
@@ -655,7 +730,7 @@ const Agenda: FC = () => {
|
|||||||
<Sparkles size={16} className="mr-2" />
|
<Sparkles size={16} className="mr-2" />
|
||||||
Valoración express
|
Valoración express
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={openCreate}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Nueva cita
|
Nueva cita
|
||||||
</Button>
|
</Button>
|
||||||
@@ -678,13 +753,6 @@ const Agenda: FC = () => {
|
|||||||
onChange={(e) => setStateFilter(e.target.value)}
|
onChange={(e) => setStateFilter(e.target.value)}
|
||||||
className="sm:max-w-[180px]"
|
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">
|
<div className="flex gap-1 sm:ml-auto">
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'lista' ? 'primary' : 'outline'}
|
variant={viewMode === 'lista' ? 'primary' : 'outline'}
|
||||||
@@ -716,6 +784,65 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Filtros rápidos */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
||||||
{viewMode !== 'lista' && (
|
{viewMode !== 'lista' && (
|
||||||
@@ -805,34 +932,34 @@ const Agenda: FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{gridHours.map((hour) => (
|
{gridSlots.map((slot) => (
|
||||||
<tr key={hour}>
|
<tr key={slot}>
|
||||||
<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">
|
<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">
|
||||||
{`${String(hour).padStart(2, '0')}:00`}
|
{horaFloatStr(slot)}
|
||||||
</td>
|
</td>
|
||||||
{gridColumns.map((col) => {
|
{gridColumns.map((col) => {
|
||||||
const cell = gridCellAppointments(col.doctorId, hour);
|
const cell = gridCellAppointments(col.doctorId, slot);
|
||||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||||
const cellKey = `${col.key}-${hour}`;
|
const cellKey = `${col.key}-${slot}`;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
|
||||||
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||||
>
|
>
|
||||||
{bloqueo ? (
|
{bloqueo ? (
|
||||||
<div
|
<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)' }}
|
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'}`}
|
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}` : ''}
|
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
|
||||||
</div>
|
</div>
|
||||||
) : cell.length === 0 ? (
|
) : 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'
|
quickFilter === 'libres'
|
||||||
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
|
? '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'
|
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
|
||||||
@@ -845,7 +972,7 @@ const Agenda: FC = () => {
|
|||||||
key={apt.id}
|
key={apt.id}
|
||||||
onClick={() => openAptDetail(apt)}
|
onClick={() => openAptDetail(apt)}
|
||||||
{...chipDragProps(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'
|
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' : ''}`}
|
} ${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}`}
|
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">
|
<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
|
Médico
|
||||||
</th>
|
</th>
|
||||||
{gridHours.map((hour) => (
|
{gridSlots.map((slot) => (
|
||||||
<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">
|
<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">
|
||||||
{`${String(hour).padStart(2, '0')}:00`}
|
{horaFloatStr(slot)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -908,34 +1035,34 @@ const Agenda: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{gridHours.map((hour) => {
|
{gridSlots.map((slot) => {
|
||||||
const cell = gridCellAppointments(col.doctorId, hour);
|
const cell = gridCellAppointments(col.doctorId, slot);
|
||||||
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
|
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
|
||||||
const cellKey = `${col.key}-${hour}`;
|
const cellKey = `${col.key}-${slot}`;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={hour}
|
key={slot}
|
||||||
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
|
||||||
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
|
||||||
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
|
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' : ''}`}
|
className={`p-0.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
|
||||||
>
|
>
|
||||||
{bloqueo ? (
|
{bloqueo ? (
|
||||||
<div
|
<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)' }}
|
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'}`}
|
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
|
||||||
>
|
>
|
||||||
<Ban size={11} className="inline" />
|
<Ban size={10} className="inline" />
|
||||||
</div>
|
</div>
|
||||||
) : cell.length === 0 ? (
|
) : cell.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border py-2 transition-colors ${
|
className={`rounded border py-1.5 transition-colors ${
|
||||||
quickFilter === 'libres'
|
quickFilter === 'libres'
|
||||||
? 'bg-brand-mint border-emerald-700'
|
? 'bg-brand-mint border-emerald-700'
|
||||||
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
|
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
|
||||||
}`}
|
}`}
|
||||||
title="Libre"
|
title={`Libre ${horaFloatStr(slot)}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
cell.map((apt) => (
|
cell.map((apt) => (
|
||||||
@@ -943,7 +1070,7 @@ const Agenda: FC = () => {
|
|||||||
key={apt.id}
|
key={apt.id}
|
||||||
onClick={() => openAptDetail(apt)}
|
onClick={() => openAptDetail(apt)}
|
||||||
{...chipDragProps(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'
|
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' : ''}`}
|
} ${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}`}
|
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||||
@@ -970,7 +1097,7 @@ const Agenda: FC = () => {
|
|||||||
title="Sin citas"
|
title="Sin citas"
|
||||||
subtitle="No hay citas para los filtros seleccionados."
|
subtitle="No hay citas para los filtros seleccionados."
|
||||||
actionLabel="Nueva cita"
|
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 }))]}
|
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
value={bloqueoForm.doctor_id}
|
value={bloqueoForm.doctor_id}
|
||||||
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
|
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
|
||||||
|
disabled={isMedico}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Fecha *"
|
label="Fecha *"
|
||||||
@@ -1127,11 +1255,68 @@ const Agenda: FC = () => {
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
<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">
|
<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
|
<Input
|
||||||
label="Buscar paciente"
|
label="Buscar paciente"
|
||||||
placeholder="Escribe nombre o teléfono..."
|
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 }))]}
|
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
value={form.doctor_id}
|
value={form.doctor_id}
|
||||||
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
|
||||||
|
disabled={isMedico}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -1197,6 +1383,8 @@ const Agenda: FC = () => {
|
|||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -1256,6 +1444,7 @@ const Agenda: FC = () => {
|
|||||||
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
value={expressForm.doctor_id}
|
value={expressForm.doctor_id}
|
||||||
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
|
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
|
||||||
|
disabled={isMedico}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -1267,6 +1456,9 @@ const Agenda: FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
label="Hora *"
|
label="Hora *"
|
||||||
type="time"
|
type="time"
|
||||||
|
min="08:00"
|
||||||
|
max="19:00"
|
||||||
|
step={900}
|
||||||
value={expressForm.time}
|
value={expressForm.time}
|
||||||
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
|
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -1375,6 +1567,9 @@ const Agenda: FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
label="Hora *"
|
label="Hora *"
|
||||||
type="time"
|
type="time"
|
||||||
|
min="08:00"
|
||||||
|
max="19:00"
|
||||||
|
step={900}
|
||||||
value={editForm.time}
|
value={editForm.time}
|
||||||
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
|
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 }))]}
|
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
|
||||||
value={editForm.doctor_id}
|
value={editForm.doctor_id}
|
||||||
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
|
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
|
||||||
|
disabled={isMedico}
|
||||||
/>
|
/>
|
||||||
<TextArea
|
<TextArea
|
||||||
label="Notas"
|
label="Notas"
|
||||||
|
|||||||
@@ -532,6 +532,9 @@ export interface FrontendUser {
|
|||||||
last_login?: string | null;
|
last_login?: string | null;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
allowed_menus?: string[] | null;
|
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 {
|
export interface Receta {
|
||||||
@@ -866,6 +869,11 @@ export const odooApi = {
|
|||||||
return data;
|
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 }> {
|
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);
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`, appointment);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -257,16 +257,17 @@ class SkeenCita(models.Model):
|
|||||||
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
||||||
duration = servicio.duration_min if servicio else 30
|
duration = servicio.duration_min if servicio else 30
|
||||||
|
|
||||||
# Horario de la clínica
|
# Horario de la clínica: 8:00 AM a 7:00 PM
|
||||||
start_time = 9.0 # 9:00 AM
|
start_time = 8.0 # 8:00 AM
|
||||||
end_time = 18.0 # 6:00 PM
|
end_time = 19.0 # 7:00 PM
|
||||||
|
|
||||||
# Generar slots
|
# Generar slots
|
||||||
slots = []
|
slots = []
|
||||||
current = start_time
|
current = start_time
|
||||||
while current + (duration / 60.0) <= end_time:
|
while current + (duration / 60.0) <= end_time + 1e-9:
|
||||||
# Verificar si está ocupado
|
# 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)
|
slot_end = slot_start + timedelta(minutes=duration)
|
||||||
|
|
||||||
domain = [
|
domain = [
|
||||||
@@ -278,8 +279,8 @@ class SkeenCita(models.Model):
|
|||||||
if not self.search(domain):
|
if not self.search(domain):
|
||||||
slots.append({
|
slots.append({
|
||||||
'time': current,
|
'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
|
return slots
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from odoo import http
|
|||||||
from odoo.http import request, Response
|
from odoo.http import request, Response
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from .auth import sign_token, current_user, require_role, verify_token
|
from .auth import sign_token, current_user, require_role, verify_token
|
||||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
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):
|
class SkeenFrontendController(http.Controller):
|
||||||
"""Controladores API públicos para el Frontend React de SKEEN"""
|
"""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
|
# Health
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -61,7 +99,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': 'Credenciales inválidas'}, 401)
|
return json_response({'status': 'error', 'message': 'Credenciales inválidas'}, 401)
|
||||||
user.write({'last_login': fields.Datetime.now()})
|
user.write({'last_login': fields.Datetime.now()})
|
||||||
token = sign_token(request.env, user)
|
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:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@@ -69,7 +107,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
def auth_me(self, **kw):
|
def auth_me(self, **kw):
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@@ -274,8 +312,29 @@ class SkeenFrontendController(http.Controller):
|
|||||||
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
def list_appointments(self, **kw):
|
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:
|
try:
|
||||||
|
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 = []
|
domain = []
|
||||||
if kw.get('date'):
|
if kw.get('date'):
|
||||||
domain.append(('date', '=', kw.get('date')))
|
domain.append(('date', '=', kw.get('date')))
|
||||||
@@ -287,7 +346,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
domain.append(('state', '=', kw.get('state')))
|
domain.append(('state', '=', kw.get('state')))
|
||||||
if kw.get('patient_id'):
|
if kw.get('patient_id'):
|
||||||
domain.append(('partner_id', '=', int(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'))))
|
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
|
||||||
if kw.get('search'):
|
if kw.get('search'):
|
||||||
s = kw.get('search')
|
s = kw.get('search')
|
||||||
@@ -344,6 +403,9 @@ class SkeenFrontendController(http.Controller):
|
|||||||
}
|
}
|
||||||
first_date = data.get('date')
|
first_date = data.get('date')
|
||||||
first_time = data.get('time')
|
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 = []
|
created = []
|
||||||
# Para paquetes, generamos citas separadas con fechas consecutivas
|
# Para paquetes, generamos citas separadas con fechas consecutivas
|
||||||
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
|
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()
|
request.env.cr.rollback()
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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)
|
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
def get_appointment(self, appointment_id, **kw):
|
def get_appointment(self, appointment_id, **kw):
|
||||||
@@ -386,6 +536,9 @@ class SkeenFrontendController(http.Controller):
|
|||||||
data = _parse_json_body()
|
data = _parse_json_body()
|
||||||
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
|
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}
|
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)
|
cita.write(vals)
|
||||||
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
|
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user