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 Pos from './pages/Pos';
|
||||||
import Visitas from './pages/Visitas';
|
import Visitas from './pages/Visitas';
|
||||||
import Consultas from './pages/Consultas';
|
import Consultas from './pages/Consultas';
|
||||||
import UltimasVisitas from './pages/UltimasVisitas';
|
|
||||||
import Servicios from './pages/Servicios';
|
import Servicios from './pages/Servicios';
|
||||||
import Medicos from './pages/Medicos';
|
import Medicos from './pages/Medicos';
|
||||||
import Monedero from './pages/Monedero';
|
import Monedero from './pages/Monedero';
|
||||||
@@ -58,7 +57,7 @@ const LoginRoute: FC = () => {
|
|||||||
// lugar de redirigir a "/" en bucle (pantalla en blanco).
|
// lugar de redirigir a "/" en bucle (pantalla en blanco).
|
||||||
const LANDING_CANDIDATOS = [
|
const LANDING_CANDIDATOS = [
|
||||||
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
|
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
|
||||||
'/expedientes', '/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
|
'/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
|
||||||
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
|
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
|
||||||
'/wacrm/leads', '/configuracion',
|
'/wacrm/leads', '/configuracion',
|
||||||
];
|
];
|
||||||
@@ -95,8 +94,8 @@ const App: FC = () => {
|
|||||||
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
||||||
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
|
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
|
||||||
<Route path="/consultas" element={<RequireAuth href="/consultas"><Consultas /></RequireAuth>} />
|
<Route path="/consultas" element={<RequireAuth href="/consultas"><Consultas /></RequireAuth>} />
|
||||||
<Route path="/expedientes" element={<RequireAuth href="/expedientes"><UltimasVisitas /></RequireAuth>} />
|
<Route path="/expedientes" element={<Navigate to="/pacientes" replace />} />
|
||||||
<Route path="/ultimas-visitas" element={<Navigate to="/expedientes" replace />} />
|
<Route path="/ultimas-visitas" element={<Navigate to="/pacientes" replace />} />
|
||||||
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
|
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
|
||||||
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
|
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
|
||||||
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
|
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ import {
|
|||||||
ClipboardPlus,
|
ClipboardPlus,
|
||||||
Banknote,
|
Banknote,
|
||||||
Percent,
|
Percent,
|
||||||
FolderClock,
|
|
||||||
Star,
|
Star,
|
||||||
Pencil,
|
Pencil,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
PanelsTopLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -66,7 +66,6 @@ const menuGroups = [
|
|||||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
|
||||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
{ 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 { user, canSee, hasRole, logout } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
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(
|
const visibleGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -215,14 +227,51 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
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">
|
<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="h-7 w-auto object-contain" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-5 px-3">
|
<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="mb-6">
|
||||||
<div className="flex items-center justify-between px-3 mb-2">
|
<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>
|
<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) => (
|
{visibleGroups.map((group) => (
|
||||||
<div key={group.label} className="mb-6">
|
<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>
|
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
|
||||||
|
)}
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href);
|
||||||
const pinned = quick.isPinned(item.href);
|
const pinned = quick.isPinned(item.href);
|
||||||
return (
|
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
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={() => setSidebarOpen(false)}
|
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
|
active
|
||||||
? 'bg-theme-accent text-theme-inverse shadow-soft'
|
? 'bg-theme-accent text-theme-inverse shadow-soft'
|
||||||
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
|
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
<span>{item.label}</span>
|
{!collapsed && <span>{item.label}</span>}
|
||||||
{item.href === '/chat' && chatUnread > 0 && (
|
{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">
|
<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}
|
{chatUnread}
|
||||||
</span>
|
</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>
|
</Link>
|
||||||
|
{!collapsed && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => quick.toggle(item.href)}
|
onClick={() => quick.toggle(item.href)}
|
||||||
@@ -316,6 +376,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
>
|
>
|
||||||
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
|
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -324,15 +385,17 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/10 shrink-0">
|
<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 space-x-3">
|
<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">
|
<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'}
|
{initials || 'US'}
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
|
<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>
|
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
|
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} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
|
<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)' }}>
|
<aside
|
||||||
<SidebarContent />
|
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>
|
</aside>
|
||||||
|
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
@@ -371,7 +448,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</aside>
|
</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)' }}>
|
<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="px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
|||||||
'/pos': ['admin', 'recepcion', 'gerencia'],
|
'/pos': ['admin', 'recepcion', 'gerencia'],
|
||||||
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||||
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||||
'/expedientes': ['admin', 'recepcion', 'medico', 'gerencia'],
|
|
||||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
||||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
|
'/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: 'Agenda', href: '/agenda' },
|
||||||
{ label: 'Pacientes', href: '/pacientes' },
|
{ label: 'Pacientes', href: '/pacientes' },
|
||||||
{ label: 'Visitas', href: '/visitas' },
|
{ label: 'Visitas', href: '/visitas' },
|
||||||
{ label: 'Últimas Visitas', href: '/expedientes' },
|
|
||||||
{ label: 'Médicos', href: '/medicos' },
|
{ label: 'Médicos', href: '/medicos' },
|
||||||
{ label: 'Servicios', href: '/servicios' },
|
{ label: 'Servicios', href: '/servicios' },
|
||||||
{ label: 'Punto de Venta', href: '/pos' },
|
{ label: 'Punto de Venta', href: '/pos' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback, useRef } from '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 } from 'lucide-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 Layout from '../components/Layout';
|
||||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +69,7 @@ const Agenda: FC = () => {
|
|||||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
|
||||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||||
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
|
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
|
||||||
@@ -92,6 +93,12 @@ const Agenda: FC = () => {
|
|||||||
sessions: 1,
|
sessions: 1,
|
||||||
});
|
});
|
||||||
const [patientSearch, setPatientSearch] = useState('');
|
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)
|
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
|
||||||
const [quickMode, setQuickMode] = useState(false);
|
const [quickMode, setQuickMode] = useState(false);
|
||||||
const [quickForm, setQuickForm] = useState({
|
const [quickForm, setQuickForm] = useState({
|
||||||
@@ -533,7 +540,7 @@ const Agenda: FC = () => {
|
|||||||
if (!createOpen) return;
|
if (!createOpen) return;
|
||||||
const t = setTimeout(async () => {
|
const t = setTimeout(async () => {
|
||||||
try {
|
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);
|
if (res.status === 'success') setPatients(res.patients);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -542,6 +549,36 @@ const Agenda: FC = () => {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [createOpen, patientSearch]);
|
}, [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) => {
|
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
|
||||||
if (!serviceId || !aptDate) {
|
if (!serviceId || !aptDate) {
|
||||||
setSlots([]);
|
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 = () => {
|
const openCreate = () => {
|
||||||
setQuickMode(false);
|
setQuickMode(false);
|
||||||
|
setEditingId(null);
|
||||||
if (medicoEmployeeId) {
|
if (medicoEmployeeId) {
|
||||||
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
|
setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) }));
|
||||||
}
|
}
|
||||||
@@ -765,6 +853,7 @@ const Agenda: FC = () => {
|
|||||||
const justDroppedRef = useRef(false);
|
const justDroppedRef = useRef(false);
|
||||||
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
||||||
setQuickMode(false);
|
setQuickMode(false);
|
||||||
|
setEditingId(null);
|
||||||
const d = opts?.date ?? date;
|
const d = opts?.date ?? date;
|
||||||
const docId = medicoEmployeeId
|
const docId = medicoEmployeeId
|
||||||
? String(medicoEmployeeId)
|
? String(medicoEmployeeId)
|
||||||
@@ -826,10 +915,23 @@ const Agenda: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const patientOptions = [
|
const patientOptions = useMemo(() => {
|
||||||
{ value: '', label: 'Seleccionar paciente...' },
|
const opts: { value: string; label: string }[] = [{ value: '', label: 'Seleccionar paciente...' }];
|
||||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
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 = [
|
const serviceOptions = [
|
||||||
{ value: '', label: 'Seleccionar servicio...' },
|
{ value: '', label: 'Seleccionar servicio...' },
|
||||||
@@ -1755,20 +1857,20 @@ const Agenda: FC = () => {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={createOpen}
|
isOpen={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}
|
||||||
title="Nueva cita"
|
title={editingId ? 'Editar cita' : 'Nueva cita'}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => { setCreateOpen(false); setEditingId(null); setCrearNuevo(false); }}>Cancelar</Button>
|
||||||
<Button onClick={quickMode ? submitQuick : createAppointment} loading={submitting}>
|
<Button onClick={editingId ? saveEditCita : quickMode ? submitQuick : createAppointment} loading={submitting}>
|
||||||
{quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
|
{editingId ? 'Guardar cambios' : quickMode ? 'Guardar cita rápida' : 'Guardar cita'}
|
||||||
</Button>
|
</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">
|
<label className={quickToggleClass}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={quickMode}
|
checked={quickMode}
|
||||||
@@ -1829,11 +1931,45 @@ const Agenda: FC = () => {
|
|||||||
value={patientSearch}
|
value={patientSearch}
|
||||||
onChange={(e) => setPatientSearch(e.target.value)}
|
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
|
<Select
|
||||||
label="Paciente *"
|
label="Paciente *"
|
||||||
options={patientOptions}
|
options={patientOptions}
|
||||||
value={form.patient_id}
|
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
|
<Select
|
||||||
label="Servicio *"
|
label="Servicio *"
|
||||||
@@ -2102,6 +2238,9 @@ const Agenda: FC = () => {
|
|||||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||||
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
||||||
</Button>
|
</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.
|
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
|
||||||
Solo en estados cobrables (oculto en cancelada/no show). */}
|
Solo en estados cobrables (oculto en cancelada/no show). */}
|
||||||
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const Pacientes: FC = () => {
|
|||||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||||
const [onlyVip, setOnlyVip] = useState(false);
|
const [onlyVip, setOnlyVip] = useState(false);
|
||||||
const [onlyRecent, setOnlyRecent] = useState(false);
|
const [onlyRecent, setOnlyRecent] = useState(false);
|
||||||
|
const [onlyStale, setOnlyStale] = useState(false);
|
||||||
const [doctorFilter, setDoctorFilter] = useState('');
|
const [doctorFilter, setDoctorFilter] = useState('');
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -198,7 +199,7 @@ const Pacientes: FC = () => {
|
|||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
incomplete: onlyIncomplete ? 1 : undefined,
|
incomplete: onlyIncomplete ? 1 : undefined,
|
||||||
vip: onlyVip ? 1 : undefined,
|
vip: onlyVip ? 1 : undefined,
|
||||||
recent: onlyRecent ? 1 : undefined,
|
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
|
||||||
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
|
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
|
||||||
});
|
});
|
||||||
if (res.status === 'success') {
|
if (res.status === 'success') {
|
||||||
@@ -213,11 +214,11 @@ const Pacientes: FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
|
}, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
odooApi.getDoctors()
|
odooApi.getDoctors()
|
||||||
@@ -458,7 +459,8 @@ const Pacientes: FC = () => {
|
|||||||
<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">
|
||||||
{([
|
{([
|
||||||
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
|
{ 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 },
|
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
|
||||||
] as const).map((chip) => (
|
] as const).map((chip) => (
|
||||||
<button
|
<button
|
||||||
@@ -474,10 +476,10 @@ const Pacientes: FC = () => {
|
|||||||
{chip.label}
|
{chip.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{(onlyVip || onlyRecent || onlyIncomplete || doctorFilter) && (
|
{(onlyVip || onlyRecent || onlyStale || onlyIncomplete || doctorFilter) && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
||||||
>
|
>
|
||||||
Quitar filtros
|
Quitar filtros
|
||||||
|
|||||||
@@ -265,11 +265,18 @@ class SkeenCita(models.Model):
|
|||||||
if b.all_day or (ini < b.time_to and fin > b.time_from):
|
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'))
|
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
|
||||||
|
|
||||||
def get_available_slots(self, date, servicio_id):
|
def get_available_slots(self, date, servicio_id, doctor_id=None):
|
||||||
"""Retorna slots disponibles para una fecha y servicio"""
|
"""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)
|
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
||||||
duration = servicio.duration_min if servicio else 30
|
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
|
# Horario de la clínica: 8:00 AM a 7:00 PM
|
||||||
start_time = 8.0 # 8:00 AM
|
start_time = 8.0 # 8:00 AM
|
||||||
end_time = 19.0 # 7:00 PM
|
end_time = 19.0 # 7:00 PM
|
||||||
@@ -289,7 +296,11 @@ class SkeenCita(models.Model):
|
|||||||
('datetime_start', '<', slot_end),
|
('datetime_start', '<', slot_end),
|
||||||
('datetime_end', '>', slot_start),
|
('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({
|
slots.append({
|
||||||
'time': current,
|
'time': current,
|
||||||
'time_str': f"{int(current):02d}:{minutes:02d}",
|
'time_str': f"{int(current):02d}:{minutes:02d}",
|
||||||
|
|||||||
@@ -647,10 +647,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
try:
|
try:
|
||||||
date_str = kw.get('date')
|
date_str = kw.get('date')
|
||||||
servicio_id = int(kw.get('servicio_id', 0))
|
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:
|
if not date_str or not servicio_id:
|
||||||
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
|
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
|
||||||
d = datetime.strptime(date_str, '%Y-%m-%d').date()
|
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})
|
return json_response({'status': 'success', 'slots': slots})
|
||||||
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)
|
||||||
@@ -1085,6 +1086,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
if kw.get('recent') == '1':
|
if kw.get('recent') == '1':
|
||||||
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
seis_meses = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
||||||
domain.append(('last_visit', '>=', seis_meses))
|
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 = max(int(kw.get('page', 1) or 1), 1)
|
||||||
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
|
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
|
||||||
Pacientes = request.env['res.partner'].sudo()
|
Pacientes = request.env['res.partner'].sudo()
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ DEFAULT_MENU_ROLES = {
|
|||||||
'/pos': ['admin', 'recepcion', 'gerencia'],
|
'/pos': ['admin', 'recepcion', 'gerencia'],
|
||||||
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
'/visitas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||||
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||||
'/expedientes': ['admin', 'recepcion', 'medico', 'gerencia'],
|
|
||||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
||||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||||
|
|||||||
Reference in New Issue
Block a user