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:
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user