Oleada B: fusion Expedientes en Pacientes (+chip sin visita 6m), sidebar colapsable SKEEN, edicion completa de citas, autocompletado de paciente, bloqueos excluidos de slots

This commit is contained in:
2026-09-11 10:38:08 +00:00
parent 0b45abc197
commit 4f49188deb
9 changed files with 295 additions and 65 deletions

View File

@@ -11,7 +11,6 @@ import Pacientes from './pages/Pacientes';
import Pos from './pages/Pos';
import Visitas from './pages/Visitas';
import Consultas from './pages/Consultas';
import UltimasVisitas from './pages/UltimasVisitas';
import Servicios from './pages/Servicios';
import Medicos from './pages/Medicos';
import Monedero from './pages/Monedero';
@@ -58,7 +57,7 @@ const LoginRoute: FC = () => {
// lugar de redirigir a "/" en bucle (pantalla en blanco).
const LANDING_CANDIDATOS = [
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
'/expedientes', '/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
'/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
'/wacrm/leads', '/configuracion',
];
@@ -95,8 +94,8 @@ const App: FC = () => {
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
<Route path="/consultas" element={<RequireAuth href="/consultas"><Consultas /></RequireAuth>} />
<Route path="/expedientes" element={<RequireAuth href="/expedientes"><UltimasVisitas /></RequireAuth>} />
<Route path="/ultimas-visitas" element={<Navigate to="/expedientes" replace />} />
<Route path="/expedientes" element={<Navigate to="/pacientes" replace />} />
<Route path="/ultimas-visitas" element={<Navigate to="/pacientes" replace />} />
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/productos" element={<Navigate to="/inventario" replace />} />

View File

@@ -26,13 +26,13 @@ import {
ClipboardPlus,
Banknote,
Percent,
FolderClock,
Star,
Pencil,
ChevronUp,
ChevronDown,
MessageCircle,
CircleDollarSign,
PanelsTopLeft,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -66,7 +66,6 @@ const menuGroups = [
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
@@ -114,6 +113,19 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const { user, canSee, hasRole, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
// medianas (menores a 1440px) el estado inicial es colapsado.
const [collapsed, setCollapsed] = useState<boolean>(() => {
const saved = localStorage.getItem('skeen_sidebar_collapsed');
if (saved !== null) return saved === '1';
return window.innerWidth < 1440;
});
const toggleCollapsed = () => {
setCollapsed((v) => {
localStorage.setItem('skeen_sidebar_collapsed', v ? '0' : '1');
return !v;
});
};
const visibleGroups = useMemo(
() =>
@@ -215,14 +227,51 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
const SidebarContent = () => (
const SidebarContent = ({ collapsed = false, showToggle = true }: { collapsed?: boolean; showToggle?: boolean }) => (
<>
<div className="h-20 flex items-center px-5 lg:px-6 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'justify-center px-2' : 'px-5 lg:px-6'}`}>
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-6 w-auto object-contain' : 'h-7 w-auto object-contain'} />
{!collapsed && showToggle && (
<button
type="button"
onClick={toggleCollapsed}
title="Colapsar menú"
aria-label="Colapsar menú"
className="ml-auto p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
>
<PanelsTopLeft size={16} />
</button>
)}
</div>
<nav className="flex-1 overflow-y-auto py-5 px-3">
{quickItems.length > 0 && quick.mode === 'menu' && (
{quickItems.length > 0 && quick.mode === 'menu' && collapsed && (
<div className="mb-5" title="Accesos rápidos">
<div className="mx-3 mb-2 border-t border-white/10" />
<ul className="space-y-1 flex flex-col items-center">
{quickItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
title={item.label}
aria-label={item.label}
className={`flex items-center justify-center w-10 h-10 rounded-xl transition-all ${
active ? 'bg-theme-accent text-theme-inverse' : 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
</Link>
</li>
);
})}
</ul>
</div>
)}
{quickItems.length > 0 && quick.mode === 'menu' && !collapsed && (
<div className="mb-6">
<div className="flex items-center justify-between px-3 mb-2">
<p className="text-[10px] uppercase tracking-wider text-white/40 font-heading">Accesos rápidos</p>
@@ -280,32 +329,43 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
)}
{visibleGroups.map((group) => (
<div key={group.label} className="mb-6">
{collapsed ? (
<div className="mx-3 mb-2 border-t border-white/10" title={group.label} />
) : (
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
)}
<ul className="space-y-1">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
const pinned = quick.isPinned(item.href);
return (
<li key={item.label} className="group flex items-center gap-1">
<li key={item.label} className={`group flex items-center ${collapsed ? 'justify-center' : 'gap-1'}`}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex-1 flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
title={item.label}
aria-label={item.label}
className={`${collapsed ? 'relative flex items-center justify-center w-10 h-10 rounded-xl' : 'flex-1 flex items-center space-x-3 px-3 py-2.5 rounded-xl'} text-sm font-medium transition-all ${
active
? 'bg-theme-accent text-theme-inverse shadow-soft'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
<span>{item.label}</span>
{!collapsed && <span>{item.label}</span>}
{item.href === '/chat' && chatUnread > 0 && (
collapsed ? (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-coral" title={`${chatUnread} sin leer`} />
) : (
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
{chatUnread}
</span>
)
)}
{active && <ChevronRight size={14} className={item.href === '/chat' && chatUnread > 0 ? '' : 'ml-auto'} />}
{active && !collapsed && <ChevronRight size={14} className={item.href === '/chat' && chatUnread > 0 ? '' : 'ml-auto'} />}
</Link>
{!collapsed && (
<button
type="button"
onClick={() => quick.toggle(item.href)}
@@ -316,6 +376,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
>
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
</button>
)}
</li>
);
})}
@@ -324,15 +385,17 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent">
<div className={`border-t border-white/10 shrink-0 ${collapsed ? 'p-2 flex flex-col items-center gap-2' : 'p-4'}`}>
<div className={`flex items-center ${collapsed ? 'flex-col gap-2' : 'space-x-3'}`}>
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent" title={user?.name || 'Usuario'}>
{initials || 'US'}
</div>
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
)}
<button
onClick={handleLogout}
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
@@ -342,14 +405,28 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<LogOut size={18} />
</button>
</div>
{collapsed && (
<button
type="button"
onClick={toggleCollapsed}
title="Expandir menú"
aria-label="Expandir menú"
className="p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
>
<PanelsTopLeft size={16} />
</button>
)}
</div>
</>
);
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
<aside className="hidden lg:flex flex-col w-64 fixed h-full z-20" style={{ backgroundColor: 'var(--sidebar-bg)' }}>
<SidebarContent />
<aside
className={`hidden lg:flex flex-col fixed h-full z-20 transition-all duration-200 ${collapsed ? 'w-16' : 'w-64'}`}
style={{ backgroundColor: 'var(--sidebar-bg)' }}
>
<SidebarContent collapsed={collapsed} />
</aside>
{sidebarOpen && (
@@ -371,7 +448,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<SidebarContent />
</aside>
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
<div className={`flex-1 min-h-screen flex flex-col w-full transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
<header className="backdrop-blur border-b sticky top-0 z-10" style={{ backgroundColor: 'var(--header-bg)', borderColor: 'var(--border)' }}>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">

View File

@@ -30,7 +30,6 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/pos': ['admin', 'recepcion', 'gerencia'],
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/expedientes': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario

View File

@@ -4,7 +4,6 @@ export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes' },
{ label: 'Visitas', href: '/visitas' },
{ label: 'Últimas Visitas', href: '/expedientes' },
{ label: 'Médicos', href: '/medicos' },
{ label: 'Servicios', href: '/servicios' },
{ label: 'Punto de Venta', href: '/pos' },

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard } from 'lucide-react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
@@ -69,6 +69,7 @@ const Agenda: FC = () => {
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
const [savingEdit, setSavingEdit] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
@@ -92,6 +93,12 @@ const Agenda: FC = () => {
sessions: 1,
});
const [patientSearch, setPatientSearch] = useState('');
const quickToggleClass = editingId
? 'hidden'
: 'inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer';
const [crearNuevo, setCrearNuevo] = useState(false);
const [nuevoPac, setNuevoPac] = useState({ name: '', phone: '' });
const [creandoPac, setCreandoPac] = useState(false);
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
const [quickMode, setQuickMode] = useState(false);
const [quickForm, setQuickForm] = useState({
@@ -533,7 +540,7 @@ const Agenda: FC = () => {
if (!createOpen) return;
const t = setTimeout(async () => {
try {
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
const res = await odooApi.getPatients({ search: patientSearch, page_size: 8 });
if (res.status === 'success') setPatients(res.patients);
} catch (err) {
console.error(err);
@@ -542,6 +549,36 @@ const Agenda: FC = () => {
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
// Crear paciente nuevo desde el selector del modal y seleccionarlo
const crearYSeleccionarPaciente = async () => {
if (!nuevoPac.name.trim() || !nuevoPac.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
try {
setCreandoPac(true);
const res = await odooApi.createPatient({
name: nuevoPac.name.trim(),
phone: nuevoPac.phone.trim(),
});
if (res.status === 'success') {
toast.success('Paciente creado');
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
setPatients((prev) => [
res.patient,
...prev.filter((p) => String(p.id) !== String(res.patient.id)),
]);
setCrearNuevo(false);
setPatientSearch('');
}
} catch (err) {
toast.error('Error al crear el paciente');
console.error(err);
} finally {
setCreandoPac(false);
}
};
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
if (!serviceId || !aptDate) {
setSlots([]);
@@ -743,8 +780,59 @@ const Agenda: FC = () => {
}
};
// Editar una cita existente: abre el modal de creación precargado y guarda con PUT.
const openEditCita = (apt: Appointment) => {
setQuickMode(false);
setDetailApt(null);
setEditMode(false);
setEditingId(apt.id);
setForm({
patient_id: String(apt.patient_id),
service_id: String(apt.service_id),
doctor_id: apt.doctor_id ? String(apt.doctor_id) : '',
date: apt.date,
time: apt.time,
notes: apt.notes || '',
sessions: 1,
});
setPatientSearch('');
setCreateOpen(true);
};
const saveEditCita = async () => {
if (!editingId) return;
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
toast.error('Completa los campos obligatorios');
return;
}
try {
setSubmitting(true);
await odooApi.updateAppointment(editingId, {
partner_id: parseInt(form.patient_id, 10),
servicio_id: parseInt(form.service_id, 10),
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
date: form.date,
time: parseTimeToFloat(form.time) ?? 0,
notes: form.notes,
} as never);
toast.success('Cita actualizada');
setCreateOpen(false);
setEditingId(null);
setDetailApt(null);
setEditMode(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo actualizar la cita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const openCreate = () => {
setQuickMode(false);
setEditingId(null);
if (medicoEmployeeId) {
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
}
@@ -765,6 +853,7 @@ const Agenda: FC = () => {
const justDroppedRef = useRef(false);
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
setQuickMode(false);
setEditingId(null);
const d = opts?.date ?? date;
const docId = medicoEmployeeId
? String(medicoEmployeeId)
@@ -826,10 +915,23 @@ const Agenda: FC = () => {
}
};
const patientOptions = [
{ value: '', label: 'Seleccionar paciente...' },
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
];
const patientOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [{ value: '', label: 'Seleccionar paciente...' }];
const list = patients.map((p) => ({
value: String(p.id),
label: `${p.name} — ${p.phone || 'sin teléfono'}${p.primary_doctor ? ` · ${p.primary_doctor}` : ''}`,
}));
// Garantiza que el paciente precargado (desde celda o edición) aparezca
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id) as Appointment | undefined;
opts.push({ value: form.patient_id, label: sel ? `${sel.patient} — (cita seleccionada)` : `Paciente #${form.patient_id}` });
}
opts.push(...list);
if (patientSearch.trim()) {
opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` });
}
return opts;
}, [patients, patientSearch, form.patient_id, appointments]);
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
@@ -1755,20 +1857,20 @@ const Agenda: FC = () => {
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nueva cita"
onClose={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}
title={editingId ? 'Editar cita' : 'Nueva cita'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={quickMode ? submitQuick : createAppointment} loading={submitting}>
{quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
<Button variant="outline" onClick={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}>Cancelar</Button>
<Button onClick={editingId ? saveEditCita : quickMode ? submitQuick : createAppointment} loading={submitting}>
{editingId ? 'Guardar cambios' : quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
</Button>
</>
}
>
<div className="space-y-4">
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<label className={quickToggleClass}>
<input
type="checkbox"
checked={quickMode}
@@ -1829,11 +1931,45 @@ const Agenda: FC = () => {
value={patientSearch}
onChange={(e) => setPatientSearch(e.target.value)}
/>
{crearNuevo && (
<div className="rounded-xl border border-theme-border p-3 space-y-3 bg-theme-bg">
<p className="text-xs text-theme-muted">Nuevo paciente: captura nombre y teléfono para crearlo y agendarle.</p>
<div className="grid grid-cols-2 gap-3">
<Input
label="Nombre *"
placeholder="Nombre del paciente"
value={nuevoPac.name}
onChange={(e) => setNuevoPac({ ...nuevoPac, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={nuevoPac.phone}
onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
Crear y seleccionar
</Button>
<Button size="sm" variant="ghost" onClick={() => { setCrearNuevo(false); setForm((f) => ({ ...f, patient_id: '' })); }}>
Cancelar
</Button>
</div>
</div>
)}
<Select
label="Paciente *"
options={patientOptions}
value={form.patient_id}
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
onChange={(e) => {
if (e.target.value === '__crear__') {
setCrearNuevo(true);
return;
}
setCrearNuevo(false);
setForm({ ...form, patient_id: e.target.value });
}}
/>
<Select
label="Servicio *"
@@ -2102,6 +2238,9 @@ const Agenda: FC = () => {
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
<CalendarClock size={16} className="mr-1.5" /> Reagendar
</Button>
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
<Pencil size={16} className="mr-1.5" /> Editar
</Button>
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
Solo en estados cobrables (oculto en cancelada/no show). */}
{!['cancelled', 'no_show'].includes(detailApt.state) && (

View File

@@ -135,6 +135,7 @@ const Pacientes: FC = () => {
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
const [onlyRecent, setOnlyRecent] = useState(false);
const [onlyStale, setOnlyStale] = useState(false);
const [doctorFilter, setDoctorFilter] = useState('');
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [modalOpen, setModalOpen] = useState(false);
@@ -198,7 +199,7 @@ const Pacientes: FC = () => {
page_size: pageSize,
incomplete: onlyIncomplete ? 1 : undefined,
vip: onlyVip ? 1 : undefined,
recent: onlyRecent ? 1 : undefined,
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
});
if (res.status === 'success') {
@@ -213,11 +214,11 @@ const Pacientes: FC = () => {
} finally {
setLoading(false);
}
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
useEffect(() => {
setPage(1);
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
}, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
useEffect(() => {
odooApi.getDoctors()
@@ -458,7 +459,8 @@ const Pacientes: FC = () => {
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: setOnlyRecent },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: 'Sin visita +6 meses', active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
] as const).map((chip) => (
<button
@@ -474,10 +476,10 @@ const Pacientes: FC = () => {
{chip.label}
</button>
))}
{(onlyVip || onlyRecent || onlyIncomplete || doctorFilter) && (
{(onlyVip || onlyRecent || onlyStale || onlyIncomplete || doctorFilter) && (
<button
type="button"
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros

View File

@@ -265,11 +265,18 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
def get_available_slots(self, date, servicio_id):
"""Retorna slots disponibles para una fecha y servicio"""
def get_available_slots(self, date, servicio_id, doctor_id=None):
"""Retorna slots disponibles para una fecha y servicio.
Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada)."""
servicio = self.env['skeen.servicio'].browse(servicio_id)
duration = servicio.duration_min if servicio else 30
# Bloqueos del médico para la fecha (día completo y por rango)
bloqueos = self.env['skeen.cita.bloqueo'].search([
('date', '=', date),
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []))
# Horario de la clínica: 8:00 AM a 7:00 PM
start_time = 8.0 # 8:00 AM
end_time = 19.0 # 7:00 PM
@@ -289,7 +296,11 @@ class SkeenCita(models.Model):
('datetime_start', '<', slot_end),
('datetime_end', '>', slot_start),
]
if not self.search(domain):
en_bloqueo = any(
b.all_day or (current < b.time_to and current + (duration / 60.0) > b.time_from)
for b in bloqueos
)
if not en_bloqueo and not self.search(domain):
slots.append({
'time': current,
'time_str': f"{int(current):02d}:{minutes:02d}",

View File

@@ -647,10 +647,11 @@ class SkeenFrontendController(http.Controller):
try:
date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0))
doctor_id = int(kw.get('doctor_id', 0)) if kw.get('doctor_id') else None
if not date_str or not servicio_id:
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
d = datetime.strptime(date_str, '%Y-%m-%d').date()
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id)
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id, doctor_id=doctor_id)
return json_response({'status': 'success', 'slots': slots})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -1085,6 +1086,10 @@ class SkeenFrontendController(http.Controller):
if kw.get('recent') == '1':
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain.append(('last_visit', '>=', seis_meses))
elif kw.get('recent') == '0':
# Sin visita en los últimos 6 meses (incluye sin visita registrada)
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
domain += ['|', ('last_visit', '=', False), ('last_visit', '<', seis_meses)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Pacientes = request.env['res.partner'].sudo()

View File

@@ -54,7 +54,6 @@ DEFAULT_MENU_ROLES = {
'/pos': ['admin', 'recepcion', 'gerencia'],
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/expedientes': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],