From 4f49188deb19e8618c8a7491b8fd6086302b5700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Fri, 11 Sep 2026 10:38:08 +0000 Subject: [PATCH] Oleada B: fusion Expedientes en Pacientes (+chip sin visita 6m), sidebar colapsable SKEEN, edicion completa de citas, autocompletado de paciente, bloqueos excluidos de slots --- frontend/src/App.tsx | 7 +- frontend/src/components/SkeenLayout.tsx | 145 +++++++++++---- frontend/src/lib/auth.tsx | 1 - frontend/src/lib/menu-items.ts | 1 - frontend/src/pages/Agenda.tsx | 167 ++++++++++++++++-- frontend/src/pages/Pacientes.tsx | 14 +- odoo-addons/skeen_citas/models/cita.py | 17 +- .../skeen_whatsapp/controllers/frontend.py | 7 +- odoo-addons/skeen_whatsapp/roles.py | 1 - 9 files changed, 295 insertions(+), 65 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5854c5f..79b772b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index 90c56bf..13c3062 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -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 = ({ 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(() => { + 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 = ({ children, title, subtitle }) => { return location.pathname === href || location.pathname.startsWith(`${href}/`); }; - const SidebarContent = () => ( + const SidebarContent = ({ collapsed = false, showToggle = true }: { collapsed?: boolean; showToggle?: boolean }) => ( <> -
- SKEEN +
+ SKEEN + {!collapsed && showToggle && ( + + )}
-
-
-
+
+
+
{initials || 'US'}
-
-

{user?.name || 'Usuario'}

-

{user ? ROLE_LABELS[user.role] : ''}

-
+ {!collapsed && ( +
+

{user?.name || 'Usuario'}

+

{user ? ROLE_LABELS[user.role] : ''}

+
+ )}
+ {collapsed && ( + + )}
); return (
- -
+
diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index a035c31..7b22cfd 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -30,7 +30,6 @@ const MENU_ROLES: Record = { '/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 diff --git a/frontend/src/lib/menu-items.ts b/frontend/src/lib/menu-items.ts index 685c1c3..9038909 100644 --- a/frontend/src/lib/menu-items.ts +++ b/frontend/src/lib/menu-items.ts @@ -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' }, diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index 4024362..fd3b8d6 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -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(null); const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]); const [slotsLoading, setSlotsLoading] = useState(false); const [bloqueos, setBloqueos] = useState([]); @@ -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 = () => { setCreateOpen(false)} - title="Nueva cita" + onClose={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }} + title={editingId ? 'Editar cita' : 'Nueva cita'} maxWidth="lg" footer={ <> - - + } >
-