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 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>} />

View File

@@ -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"

View File

@@ -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;

View File

@@ -256,19 +256,20 @@ class SkeenCita(models.Model):
"""Retorna slots disponibles para una fecha y servicio""" """Retorna slots disponibles para una fecha y servicio"""
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 = [
('date', '=', date), ('date', '=', date),
('state', 'not in', ('cancelled', 'no_show')), ('state', 'not in', ('cancelled', 'no_show')),
@@ -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

View File

@@ -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,9 +312,30 @@ 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:
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'): if kw.get('date'):
domain.append(('date', '=', kw.get('date'))) domain.append(('date', '=', kw.get('date')))
if kw.get('date_from'): if kw.get('date_from'):
@@ -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: