Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
220
frontend/src/components/CatalogoPanel.tsx
Normal file
220
frontend/src/components/CatalogoPanel.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, BookMarked } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from './ui';
|
||||
import { odooApi, type Diagnostico, type Procedimiento } from '../services/odoo';
|
||||
|
||||
type CatalogoItem = Diagnostico | Procedimiento;
|
||||
|
||||
interface CatalogoPanelProps {
|
||||
kind: 'diagnosticos' | 'procedimientos';
|
||||
titulo: string;
|
||||
}
|
||||
|
||||
const CatalogoPanel: FC<CatalogoPanelProps> = ({ kind, titulo }) => {
|
||||
const [items, setItems] = useState<CatalogoItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CatalogoItem | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', categoria: '', descripcion: '' });
|
||||
|
||||
const api = kind === 'diagnosticos'
|
||||
? { list: odooApi.getDiagnosticos, create: odooApi.createDiagnostico, update: odooApi.updateDiagnostico, remove: odooApi.deleteDiagnostico }
|
||||
: { list: odooApi.getProcedimientos, create: odooApi.createProcedimiento, update: odooApi.updateProcedimiento, remove: odooApi.deleteProcedimiento };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.list(search || undefined);
|
||||
if (res.status === 'success') setItems(res.items);
|
||||
} catch (err) {
|
||||
toast.error(`Error al cargar ${titulo.toLowerCase()}`);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, kind]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ name: '', categoria: '', descripcion: '' });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: CatalogoItem) => {
|
||||
setEditing(item);
|
||||
setForm({ name: item.name, categoria: item.categoria, descripcion: item.descripcion });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await api.update(editing.id, form);
|
||||
toast.success('Actualizado');
|
||||
} else {
|
||||
await api.create(form);
|
||||
toast.success('Creado');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eliminar = async (item: CatalogoItem) => {
|
||||
if (!window.confirm(`¿Eliminar "${item.name}"?`)) return;
|
||||
try {
|
||||
await api.remove(item.id);
|
||||
toast.success('Eliminado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al eliminar');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o categoría..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="sm:ml-auto">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-12 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
title={`Sin ${titulo.toLowerCase()}`}
|
||||
subtitle={`Agrega el primero al catálogo de ${titulo.toLowerCase()}.`}
|
||||
actionLabel="Nuevo"
|
||||
onAction={openCreate}
|
||||
icon={<BookMarked size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Categoría</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Descripción</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{item.name}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{item.categoria || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
<span className="block max-w-md truncate">{item.descripcion}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(item)} title="Eliminar">
|
||||
<Trash2 size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{items.map((item) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subtitle={item.categoria || undefined}
|
||||
rows={[{ label: 'Descripción', value: item.descripcion.slice(0, 60) || '-' }]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}><Pencil size={16} className="text-theme-muted" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(item)}><Trash2 size={16} className="text-rose-500" /></Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal crear/editar */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar' : 'Nuevo'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Categoría"
|
||||
value={form.categoria}
|
||||
onChange={(e) => setForm({ ...form, categoria: e.target.value })}
|
||||
/>
|
||||
<TextArea
|
||||
label="Descripción"
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CatalogoPanel;
|
||||
569
frontend/src/components/ClasicoLayout.tsx
Normal file
569
frontend/src/components/ClasicoLayout.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Users,
|
||||
Stethoscope,
|
||||
FlaskConical,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
Receipt,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Target,
|
||||
Boxes,
|
||||
ClipboardList,
|
||||
ClipboardPlus,
|
||||
FolderClock,
|
||||
Star,
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Árbol de menús con las etiquetas y submenús del sistema legacy
|
||||
interface MenuChild {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface MenuEntry {
|
||||
label: string;
|
||||
icon: FC<{ size?: number | string; className?: string }>;
|
||||
href?: string;
|
||||
children?: MenuChild[];
|
||||
}
|
||||
|
||||
const menuTree: MenuEntry[] = [
|
||||
{ label: 'Inicio', icon: LayoutDashboard, href: '/' },
|
||||
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||
{ label: 'Consultas Médicas', icon: ClipboardPlus, href: '/consultas' },
|
||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Cosmetología', icon: FlaskConical, href: '/servicios' },
|
||||
{
|
||||
label: 'Configuración', icon: Settings,
|
||||
children: [
|
||||
{ label: 'Clínica', href: '/configuracion?tab=clinica' },
|
||||
{ label: 'Usuarios', href: '/configuracion?tab=usuarios' },
|
||||
{ label: 'Recetas', href: '/configuracion?tab=recetas' },
|
||||
{ label: 'Diagnósticos', href: '/configuracion?tab=catalogos&sub=diagnosticos' },
|
||||
{ label: 'Procedimientos médicos', href: '/configuracion?tab=catalogos&sub=procedimientos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reportes', icon: BarChart3,
|
||||
children: [
|
||||
{ label: 'Mov. diario', href: '/reportes?tab=diario' },
|
||||
{ label: 'Cortes de caja', href: '/reportes?tab=cortes' },
|
||||
{ label: 'Ingresos', href: '/reportes?tab=ingresos' },
|
||||
{ label: 'Inventario', href: '/reportes?tab=inventario' },
|
||||
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Exportar correos', href: '/reportes?tab=exportar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administrativo', icon: DollarSign,
|
||||
children: [
|
||||
{ label: 'Adeudos clientes', href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Pagos | Servicios', href: '/reportes?tab=pagos-servicios' },
|
||||
{ label: 'Pagos | Clientes', href: '/reportes?tab=pagos-clientes' },
|
||||
{ label: 'Devoluciones', href: '/reportes?tab=devoluciones' },
|
||||
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
|
||||
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
||||
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
|
||||
{ label: 'Top ventas', href: '/reportes?tab=top-clientes' },
|
||||
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
|
||||
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
|
||||
{ label: 'Cortes de caja', href: '/cortes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Monedero', icon: Wallet,
|
||||
children: [
|
||||
{ label: 'Monederos', href: '/monedero' },
|
||||
{ label: 'Reporte puntos', href: '/monedero?tab=reporte' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Comunicación', icon: MessageSquare,
|
||||
children: [
|
||||
{ label: 'Cumpleañeros', href: '/cumpleanos' },
|
||||
{ label: 'Mensajes', href: '/wacrm/messages' },
|
||||
],
|
||||
},
|
||||
{ label: 'CRM', icon: Target, href: '/wacrm/leads' },
|
||||
{ label: 'Comprobantes del día', icon: Receipt, href: '/pagos' },
|
||||
{
|
||||
label: 'Consumibles', icon: Boxes,
|
||||
children: [
|
||||
{ label: 'Inventario actual', href: '/inventario' },
|
||||
{ label: 'Compras', href: '/inventario?tab=compras' },
|
||||
{ label: 'Bajas', href: '/inventario?tab=bajas' },
|
||||
],
|
||||
},
|
||||
{ label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Clientes', icon: Users, href: '/pacientes' },
|
||||
];
|
||||
|
||||
// Hojas planas (para accesos rápidos y dock)
|
||||
const menuLeaves = menuTree.flatMap((e) =>
|
||||
e.children ? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href })) : [{ label: e.label, icon: e.icon, href: e.href as string }]
|
||||
);
|
||||
|
||||
export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const visibleLeaves = useMemo(
|
||||
() => menuLeaves.filter((it) => canSee(it.href)),
|
||||
[canSee]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = visibleLeaves;
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => menuLeaves.find((it) => it.href === href) ?? null)
|
||||
.filter((it): it is (typeof menuLeaves)[number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase())
|
||||
.join('');
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null);
|
||||
const [goalModalOpen, setGoalModalOpen] = useState(false);
|
||||
const [goalInput, setGoalInput] = useState('');
|
||||
const [goalSaving, setGoalSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const fetchGoal = () => {
|
||||
odooApi.getSalesGoal()
|
||||
.then((res) => {
|
||||
if (alive && res.status === 'success') {
|
||||
setGoal({ goal: res.goal, current: res.current, pct: res.pct });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchGoal();
|
||||
const id = setInterval(fetchGoal, 60000);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const openGoalModal = () => {
|
||||
setGoalInput(goal?.goal ? String(goal.goal) : '');
|
||||
setGoalModalOpen(true);
|
||||
};
|
||||
|
||||
const saveGoal = async () => {
|
||||
const v = parseFloat(goalInput);
|
||||
if (Number.isNaN(v) || v < 0) return;
|
||||
try {
|
||||
setGoalSaving(true);
|
||||
await odooApi.setSalesGoal(v);
|
||||
setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g));
|
||||
setGoalModalOpen(false);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setGoalSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`;
|
||||
const pctClamped = Math.min(goal?.pct ?? 0, 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen) return;
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const [path, query] = href.split('?');
|
||||
if (path === '/') {
|
||||
if (location.pathname !== '/') return false;
|
||||
} else if (location.pathname !== path && !location.pathname.startsWith(`${path}/`)) {
|
||||
return false;
|
||||
}
|
||||
if (query) {
|
||||
return query.split('&').every((p) => location.search.includes(p));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Grupos de submenú abiertos (auto-abre el de la ruta activa)
|
||||
const [openGroups, setOpenGroups] = useState<string[]>(() => {
|
||||
const current = `${location.pathname}${location.search}`;
|
||||
return menuTree
|
||||
.filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))))
|
||||
.map((e) => e.label);
|
||||
});
|
||||
|
||||
const toggleGroup = (label: string) =>
|
||||
setOpenGroups((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
{/* Sucursal, como en el sistema legacy */}
|
||||
<p className="text-center text-sm font-medium text-white py-3 shrink-0" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
Rosarito
|
||||
</p>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: 'var(--sidebar-group-text)' }}>Accesos rápidos</p>
|
||||
<span className="flex items-center gap-1">
|
||||
<QuickLinksModePicker direction="down" align="right" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.setEditing(!quick.editing)}
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: 'var(--sidebar-group-text)' }}
|
||||
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
|
||||
>
|
||||
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
{quickItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className="flex items-center">
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
|
||||
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
|
||||
color: active ? '#fff' : 'var(--sidebar-text)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
{quick.editing && (
|
||||
<span className="flex items-center gap-0.5 shrink-0 pr-2">
|
||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
||||
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Subir">
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
||||
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Bajar">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.remove(item.href)}
|
||||
className="hover:text-rose-400 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Quitar">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<ul>
|
||||
{menuTree.map((entry) => {
|
||||
const Icon = entry.icon;
|
||||
// Hojas visibles de este entry
|
||||
const leaves = entry.children
|
||||
? entry.children.filter((c) => canSee(c.href))
|
||||
: (entry.href && canSee(entry.href) ? [{ label: entry.label, href: entry.href }] : []);
|
||||
if (leaves.length === 0) return null;
|
||||
|
||||
// Entry simple (sin hijos)
|
||||
if (!entry.children) {
|
||||
const active = isActive(entry.href as string);
|
||||
const pinned = quick.isPinned(entry.href as string);
|
||||
return (
|
||||
<li key={entry.label} className="group flex items-center">
|
||||
<Link
|
||||
to={entry.href as string}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
|
||||
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
|
||||
color: active ? '#fff' : 'var(--sidebar-text)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{entry.label}</span>
|
||||
{active && <ChevronRight size={13} className="ml-auto" />}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.toggle(entry.href as string)}
|
||||
className={`shrink-0 pr-2 transition-all ${
|
||||
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
||||
}`}
|
||||
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
||||
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
||||
>
|
||||
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Entry con submenú (treeview AdminLTE)
|
||||
const open = openGroups.includes(entry.label);
|
||||
const childActive = leaves.some((c) => isActive(c.href));
|
||||
return (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(entry.label)}
|
||||
className="w-full flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: childActive ? 'var(--sidebar-active-bg)' : 'transparent',
|
||||
borderLeft: `3px solid ${childActive ? 'var(--sidebar-accent)' : 'transparent'}`,
|
||||
color: childActive ? '#fff' : 'var(--sidebar-text)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
||||
onMouseLeave={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{entry.label}</span>
|
||||
<ChevronDown size={14} className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
|
||||
{leaves.map((child) => {
|
||||
const active = isActive(child.href);
|
||||
const pinned = quick.isPinned(child.href);
|
||||
return (
|
||||
<li key={child.href} className="group flex items-center">
|
||||
<Link
|
||||
to={child.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex-1 flex items-center pl-11 pr-4 py-2 text-[13px] transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'rgba(0,0,0,0.25)' : 'transparent',
|
||||
color: active ? '#fff' : 'var(--sidebar-text)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: active ? 'var(--sidebar-accent)' : 'var(--sidebar-text)' }} />
|
||||
<span>{child.label}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.toggle(child.href)}
|
||||
className={`shrink-0 pr-2 transition-all ${
|
||||
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
||||
}`}
|
||||
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
||||
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
||||
>
|
||||
<Star size={13} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-3 shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{ backgroundColor: 'var(--sidebar-accent)', color: '#3E454C' }}
|
||||
>
|
||||
{initials || 'US'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--sidebar-text)' }}>{user ? ROLE_LABELS[user.role] : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 transition-colors hover:text-white"
|
||||
style={{ color: 'var(--sidebar-text)' }}
|
||||
title="Cerrar sesión"
|
||||
aria-label="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
|
||||
{/* Header fijo */}
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-30 h-[50px] flex items-stretch"
|
||||
style={{ backgroundColor: 'var(--header-bg)' }}
|
||||
>
|
||||
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
|
||||
<span className="text-[26px] font-bold tracking-tight" style={{ color: '#333333' }}>
|
||||
SKEEN<sup className="text-xs font-semibold">®</sup>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden text-white"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Abrir menú"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>SKEEN<sup className="text-[10px]">®</sup></span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
||||
{goal && goal.goal > 0 && (
|
||||
<button
|
||||
onClick={hasRole('admin') ? openGoalModal : undefined}
|
||||
className={`hidden xl:flex flex-col w-44 text-left rounded-[2px] px-3 py-1 ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.25)' }}
|
||||
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] mb-0.5 text-white/80">
|
||||
<span className="flex items-center gap-1"><Target size={11} /> Meta del mes</span>
|
||||
<span className="font-bold text-white">{Math.round(goal.pct)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 overflow-hidden" style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
|
||||
<div className="h-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : '#fff' }} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] mt-0.5 text-white/80">
|
||||
<span className="font-semibold text-white">{fmtMoney(goal.current)}</span>
|
||||
<span>de {fmtMoney(goal.goal)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{goal && goal.goal <= 0 && hasRole('admin') && (
|
||||
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
|
||||
<Target size={14} className="mr-1.5" /> Definir meta
|
||||
</Button>
|
||||
)}
|
||||
<GlobalSearch className="hidden md:block" />
|
||||
<NotificationBell buttonClassName="p-2 rounded-[2px] relative transition text-white/90 hover:bg-black/10" />
|
||||
<ThemePicker triggerClassName="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-[2px] text-xs font-medium text-white/90 transition hover:bg-black/10" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar desktop */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col w-[230px] fixed top-[50px] bottom-0 left-0 z-20"
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Drawer móvil */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
|
||||
)}
|
||||
<aside
|
||||
className={`fixed top-[50px] bottom-0 left-0 w-[230px] z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Contenido */}
|
||||
<main
|
||||
className={`lg:ml-[230px] pt-[50px] min-h-screen ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}
|
||||
>
|
||||
<div className="px-4 sm:px-6 py-5">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<QuickLinksDock items={dockItems} />
|
||||
|
||||
<Modal
|
||||
isOpen={goalModalOpen}
|
||||
onClose={() => setGoalModalOpen(false)}
|
||||
title="Meta de ventas del mes"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
|
||||
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClasicoLayout;
|
||||
160
frontend/src/components/GlobalSearch.tsx
Normal file
160
frontend/src/components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Users, Calendar, Briefcase } from 'lucide-react';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface Results {
|
||||
patients: { id: number; name: string; phone: string }[];
|
||||
appointments: { id: number; patient: string; service: string; date: string; time: string }[];
|
||||
services: { id: number; name: string; price: number }[];
|
||||
}
|
||||
|
||||
const EMPTY: Results = { patients: [], appointments: [], services: [] };
|
||||
|
||||
interface GlobalSearchProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Búsqueda global del header: dropdown con autocompletado (pacientes, citas, servicios). */
|
||||
export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Results>(EMPTY);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cerrar al hacer click fuera
|
||||
useEffect(() => {
|
||||
const close = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, []);
|
||||
|
||||
// Búsqueda con debounce
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
setResults(EMPTY);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await odooApi.globalSearch(q);
|
||||
if (res.status === 'success') {
|
||||
setResults({
|
||||
patients: res.patients || [],
|
||||
appointments: res.appointments || [],
|
||||
services: res.services || [],
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
} catch {
|
||||
setResults(EMPTY);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
const go = (path: string) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const hasResults = results.patients.length + results.appointments.length + results.services.length > 0;
|
||||
const showDropdown = open && query.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className={`relative ${className}`}>
|
||||
<div className="flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => { if (hasResults) setOpen(true); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 placeholder:text-theme-muted/60"
|
||||
style={{ color: 'var(--text-heading)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 max-h-[70vh] overflow-y-auto rounded-2xl border border-theme-border bg-theme-surface shadow-card z-50">
|
||||
{searching && (
|
||||
<p className="px-4 py-3 text-xs text-theme-muted">Buscando...</p>
|
||||
)}
|
||||
{!searching && !hasResults && (
|
||||
<p className="px-4 py-3 text-xs text-theme-muted">Sin resultados para "{query.trim()}"</p>
|
||||
)}
|
||||
|
||||
{results.patients.length > 0 && (
|
||||
<div className="p-1.5">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Pacientes</p>
|
||||
{results.patients.map((p) => (
|
||||
<button
|
||||
key={`p-${p.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/pacientes?q=${encodeURIComponent(p.phone || p.name)}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
>
|
||||
<Users size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">{p.phone}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.appointments.length > 0 && (
|
||||
<div className="p-1.5 border-t border-theme-border">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Citas próximas</p>
|
||||
{results.appointments.map((a) => (
|
||||
<button
|
||||
key={`a-${a.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/agenda?date=${a.date}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
>
|
||||
<Calendar size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{a.patient} · {a.service}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">{a.date.slice(5)} {a.time}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.services.length > 0 && (
|
||||
<div className="p-1.5 border-t border-theme-border">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Servicios</p>
|
||||
{results.services.map((s) => (
|
||||
<button
|
||||
key={`s-${s.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/servicios?q=${encodeURIComponent(s.name)}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
>
|
||||
<Briefcase size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{s.name}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">${s.price}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearch;
|
||||
@@ -7,15 +7,13 @@ import {
|
||||
Users,
|
||||
Stethoscope,
|
||||
Briefcase,
|
||||
Package,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
Store,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
@@ -24,13 +22,23 @@ import {
|
||||
Target,
|
||||
Cake,
|
||||
Boxes,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
ClipboardPlus,
|
||||
Banknote,
|
||||
Percent,
|
||||
FolderClock,
|
||||
Sparkles,
|
||||
Palette,
|
||||
Star,
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -53,16 +61,20 @@ const menuSections = [
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
{ 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: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Productos', icon: Package, href: '/productos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operaciones',
|
||||
items: [
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||
{ label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
|
||||
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
|
||||
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
|
||||
@@ -72,9 +84,9 @@ const menuSections = [
|
||||
label: 'Analítica y más',
|
||||
items: [
|
||||
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
|
||||
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
|
||||
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
|
||||
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -90,7 +102,6 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { toggleTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
|
||||
@@ -102,6 +113,32 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
[canSee, user]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = useMemo(() => {
|
||||
const all = [...topNavItems, ...menuSections.flatMap((s) => s.items)];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((it) => {
|
||||
if (seen.has(it.href) || !canSee(it.href)) return false;
|
||||
seen.add(it.href);
|
||||
return true;
|
||||
});
|
||||
}, [canSee]);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => {
|
||||
for (const s of menuSections) {
|
||||
const found = s.items.find((it) => it.href === href);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
@@ -259,33 +296,9 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-40 placeholder:text-theme-muted/60"
|
||||
style={{ color: 'var(--text-heading)' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title="Cambiar a SKEEN"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
|
||||
style={{ backgroundColor: 'var(--bg)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--bg)'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
SKEEN
|
||||
</button>
|
||||
<GlobalSearch className="hidden md:block" />
|
||||
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
|
||||
<ThemePicker />
|
||||
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
|
||||
{initials || 'US'}
|
||||
@@ -335,6 +348,76 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<div className="w-full flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
|
||||
<Star size={14} style={{ color: 'var(--accent)' }} />
|
||||
Accesos rápidos
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<QuickLinksModePicker direction="down" align="right" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.setEditing(!quick.editing)}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
|
||||
>
|
||||
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{quickItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className="flex items-center gap-1">
|
||||
<Link
|
||||
to={item.href}
|
||||
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
||||
e.currentTarget.style.color = 'var(--text-heading)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</Link>
|
||||
{quick.editing && (
|
||||
<span className="flex items-center gap-0.5 shrink-0">
|
||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
||||
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Subir">
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
||||
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Bajar">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.remove(item.href)}
|
||||
style={{ color: 'var(--text-muted)' }} title="Quitar">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{visibleSections.map((section) => (
|
||||
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
@@ -350,11 +433,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
const pinned = quick.isPinned(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<li key={item.href} className="group flex items-center gap-1">
|
||||
<Link
|
||||
to={item.href}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
||||
@@ -375,6 +459,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.toggle(item.href)}
|
||||
className={`shrink-0 transition-all ${pinned ? '' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
style={{ color: pinned ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
||||
>
|
||||
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -385,11 +478,13 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<main className={`flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8 ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<QuickLinksDock items={dockItems} />
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{menuOpen && (
|
||||
<>
|
||||
@@ -402,6 +497,32 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{quickItems.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pt-1 text-[10px] uppercase tracking-wider font-medium flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<Star size={11} style={{ color: 'var(--accent)' }} /> Accesos rápidos
|
||||
</p>
|
||||
{quickItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={`quick-${item.href}`}
|
||||
to={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} /> {item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="border-b my-2" style={{ borderColor: 'var(--border)' }} />
|
||||
</>
|
||||
)}
|
||||
{topNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import SkeenLayout from './SkeenLayout';
|
||||
import HomeNestLayout from './HomeNestLayout';
|
||||
import ClasicoLayout from './ClasicoLayout';
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -14,6 +15,9 @@ const Layout: FC<LayoutProps> = (props) => {
|
||||
if (theme === 'homenest') {
|
||||
return <HomeNestLayout {...props} />;
|
||||
}
|
||||
if (theme === 'clasico') {
|
||||
return <ClasicoLayout {...props} />;
|
||||
}
|
||||
return <SkeenLayout {...props} />;
|
||||
};
|
||||
|
||||
|
||||
122
frontend/src/components/NotificationBell.tsx
Normal file
122
frontend/src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react';
|
||||
import { odooApi, type AppNotification } from '../services/odoo';
|
||||
|
||||
const TIPO_ICON: Record<string, typeof Boxes> = {
|
||||
stock_bajo: Boxes,
|
||||
caducidades: CalendarX,
|
||||
citas_pendientes_hoy: Clock,
|
||||
leads_sin_asignar: Target,
|
||||
conversaciones_sin_leer: MessageSquare,
|
||||
};
|
||||
|
||||
const SEVERITY_CLASS: Record<string, string> = {
|
||||
info: 'bg-theme-accent-bg text-theme-heading',
|
||||
warning: 'bg-theme-warning text-theme-warning-text',
|
||||
danger: 'bg-rose-100 text-rose-700',
|
||||
};
|
||||
|
||||
interface NotificationBellProps {
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName }) => {
|
||||
const navigate = useNavigate();
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await odooApi.getNotifications();
|
||||
if (res.status === 'success') setNotifications(res.notifications);
|
||||
} catch {
|
||||
// silencioso: la campanita no debe molestar si falla
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Polling 60s + al enfocar la ventana
|
||||
useEffect(() => {
|
||||
load();
|
||||
const interval = setInterval(load, 60000);
|
||||
const onFocus = () => load();
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
const total = notifications.length;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Notificaciones"
|
||||
aria-label="Notificaciones"
|
||||
className={buttonClassName || 'p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'}
|
||||
>
|
||||
<Bell size={18} />
|
||||
{total > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-rose-500 text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-2xl border border-theme-border bg-theme-surface shadow-card overflow-hidden">
|
||||
<p className="px-4 py-2.5 text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Notificaciones
|
||||
</p>
|
||||
{loading && notifications.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted text-center py-6">Cargando...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted text-center py-6">Sin notificaciones</p>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-y-auto">
|
||||
{notifications.map((n) => {
|
||||
const Icon = TIPO_ICON[n.tipo] || Bell;
|
||||
return (
|
||||
<li key={n.tipo}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); navigate(n.link); }}
|
||||
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-theme-bg transition border-b border-theme-border last:border-0"
|
||||
>
|
||||
<span className={`mt-0.5 w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${SEVERITY_CLASS[n.severity] || SEVERITY_CLASS.info}`}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-theme-heading truncate">{n.titulo}</span>
|
||||
<span className="block text-xs text-theme-muted truncate">{n.detalle}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBell;
|
||||
185
frontend/src/components/PatientAdjuntos.tsx
Normal file
185
frontend/src/components/PatientAdjuntos.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { FileText, X, Upload } from 'lucide-react';
|
||||
import { Button, Modal, Skeleton, toast } from './ui';
|
||||
import { odooApi, type PatientAdjunto } from '../services/odoo';
|
||||
|
||||
interface PatientAdjuntosProps {
|
||||
patientId: number;
|
||||
kind: 'expediente' | 'imagen';
|
||||
editable?: boolean;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export const PatientAdjuntos: FC<PatientAdjuntosProps> = ({ patientId, kind, editable = true, onChange }) => {
|
||||
const [adjuntos, setAdjuntos] = useState<PatientAdjunto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [visor, setVisor] = useState<PatientAdjunto | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getPatientAdjuntos(patientId, kind);
|
||||
if (res.status === 'success') setAdjuntos(res.adjuntos);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar archivos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [patientId, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const subir = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > MAX_BYTES) {
|
||||
toast.error('El archivo excede 10 MB');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const file_b64 = String(reader.result || '').split(',')[1] || '';
|
||||
try {
|
||||
setUploading(true);
|
||||
const res = await odooApi.uploadPatientAdjunto(patientId, {
|
||||
kind,
|
||||
name: file.name,
|
||||
file_b64,
|
||||
mimetype: file.type || 'application/octet-stream',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
toast.success('Archivo subido');
|
||||
await load();
|
||||
onChange?.();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al subir el archivo');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const borrar = async (adjId: number) => {
|
||||
if (!window.confirm('¿Eliminar este archivo?')) return;
|
||||
try {
|
||||
await odooApi.deletePatientAdjunto(adjId);
|
||||
toast.success('Archivo eliminado');
|
||||
await load();
|
||||
onChange?.();
|
||||
} catch (err) {
|
||||
toast.error('Error al eliminar el archivo');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const imagenes = adjuntos.filter((a) => a.mimetype.startsWith('image/'));
|
||||
const docs = adjuntos.filter((a) => !a.mimetype.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{editable && (
|
||||
<div className="mb-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={kind === 'imagen' ? 'image/*' : undefined}
|
||||
className="hidden"
|
||||
onChange={subir}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} loading={uploading}>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{kind === 'imagen' ? 'Subir foto' : 'Escanear / Subir expediente'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={2} className="h-16 w-full" />
|
||||
) : adjuntos.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">
|
||||
{kind === 'imagen' ? 'Sin fotos en la galería.' : 'Sin expediente escaneado.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{imagenes.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
||||
{imagenes.map((a) => (
|
||||
<div key={a.id} className="relative">
|
||||
<button type="button" onClick={() => setVisor(a)} className="w-full">
|
||||
<img
|
||||
src={odooApi.patientAdjuntoUrl(a.url)}
|
||||
alt={a.name}
|
||||
className="w-full h-24 object-cover rounded-xl border border-theme-border"
|
||||
/>
|
||||
</button>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => borrar(a.id)}
|
||||
title="Eliminar"
|
||||
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{docs.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{docs.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
|
||||
<a
|
||||
href={odooApi.patientAdjuntoUrl(a.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center text-theme-heading hover:underline min-w-0"
|
||||
>
|
||||
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
|
||||
<span className="truncate">{a.name}</span>
|
||||
</a>
|
||||
{editable && (
|
||||
<Button variant="ghost" size="sm" onClick={() => borrar(a.id)} title="Eliminar">
|
||||
<X size={14} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Visor de imagen */}
|
||||
<Modal
|
||||
isOpen={!!visor}
|
||||
onClose={() => setVisor(null)}
|
||||
title={visor?.name || 'Imagen'}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{visor && (
|
||||
<img
|
||||
src={odooApi.patientAdjuntoUrl(visor.url)}
|
||||
alt={visor.name}
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientAdjuntos;
|
||||
145
frontend/src/components/QuickLinksDock.tsx
Normal file
145
frontend/src/components/QuickLinksDock.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { List, PanelRight, PanelBottom, Settings2 } from 'lucide-react';
|
||||
import { useQuickLinks, QUICK_LINKS_MODE_LABELS, type QuickLinksMode } from '../lib/quicklinks';
|
||||
|
||||
interface DockItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: FC<{ size?: number | string; className?: string }>;
|
||||
}
|
||||
|
||||
interface QuickLinksDockProps {
|
||||
/** Items visibles del menú (para resolver icono/label de cada href fijado) */
|
||||
items: DockItem[];
|
||||
}
|
||||
|
||||
const MODE_ICONS: Record<QuickLinksMode, FC<{ size?: number | string; className?: string }>> = {
|
||||
menu: List,
|
||||
right: PanelRight,
|
||||
bottom: PanelBottom,
|
||||
};
|
||||
|
||||
/** Selector de presentación de los accesos rápidos (menú / derecha / abajo). */
|
||||
export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left' | 'right' }> = ({
|
||||
direction = 'up',
|
||||
align = 'right',
|
||||
}) => {
|
||||
const quick = useQuickLinks();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-xl text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading transition-colors"
|
||||
title="Cambiar presentación de accesos rápidos"
|
||||
>
|
||||
<Settings2 size={15} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-50 w-56 rounded-2xl border border-theme-border bg-theme-surface shadow-card p-1.5 ${
|
||||
direction === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'
|
||||
} ${align === 'right' ? 'right-0' : 'left-0'}`}
|
||||
>
|
||||
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
|
||||
Mostrar accesos rápidos
|
||||
</p>
|
||||
{(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => {
|
||||
const ModeIcon = MODE_ICONS[m];
|
||||
const selected = quick.mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => { quick.setMode(m); setOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm transition-colors ${
|
||||
selected ? 'bg-theme-accent-bg text-theme-heading font-medium' : 'text-theme-muted hover:bg-theme-bg hover:text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
<ModeIcon size={15} />
|
||||
{QUICK_LINKS_MODE_LABELS[m]}
|
||||
{selected && <span className="ml-auto text-brand-coral">●</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Barra flotante de accesos rápidos (iconos), a la derecha o abajo según
|
||||
* la preferencia del usuario. En modo 'menu' no se muestra (la sección
|
||||
* va dentro del sidebar de cada layout).
|
||||
*/
|
||||
export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
|
||||
const location = useLocation();
|
||||
const quick = useQuickLinks();
|
||||
|
||||
if (quick.mode === 'menu') return null;
|
||||
|
||||
const dockItems = quick.links
|
||||
.map((href) => items.find((it) => it.href === href))
|
||||
.filter((it): it is DockItem => it !== undefined);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
href === '/' ? location.pathname === '/' : location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||
|
||||
const isRight = quick.mode === 'right';
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Accesos rápidos"
|
||||
className={
|
||||
isRight
|
||||
? 'fixed right-0 top-1/2 -translate-y-1/2 z-40 hidden lg:flex flex-col items-center gap-1 py-2 px-1.5 mr-2 rounded-2xl border border-theme-border bg-theme-surface shadow-card'
|
||||
: 'fixed bottom-3 left-1/2 -translate-x-1/2 z-40 flex items-center gap-1 px-2 py-1.5 rounded-2xl border border-theme-border bg-theme-surface shadow-card'
|
||||
}
|
||||
>
|
||||
{dockItems.length === 0 && (
|
||||
<span className={`text-[11px] text-theme-muted px-2 ${isRight ? 'py-2 [writing-mode:vertical-rl]' : ''}`}>
|
||||
Fija accesos con la ★ del menú
|
||||
</span>
|
||||
)}
|
||||
{dockItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-xl transition-colors ${
|
||||
active
|
||||
? 'bg-theme-accent text-theme-inverse shadow-soft'
|
||||
: 'text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className={isRight ? 'border-t border-theme-border pt-1 mt-1' : 'border-l border-theme-border pl-1 ml-1'}>
|
||||
<QuickLinksModePicker direction={isRight ? 'down' : 'up'} align="right" />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickLinksDock;
|
||||
139
frontend/src/components/RecetaPrint.tsx
Normal file
139
frontend/src/components/RecetaPrint.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button, Modal, Select, TextArea } from './ui';
|
||||
import { odooApi, type Receta } from '../services/odoo';
|
||||
|
||||
interface ClinicSettings {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
const clinicSettings = (): ClinicSettings => {
|
||||
try {
|
||||
const saved = localStorage.getItem('skeen_clinic_settings');
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name: 'SKEEN Derma Experts', phone: '', address: '' };
|
||||
};
|
||||
|
||||
interface RecetaSectionProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onPrint: () => void;
|
||||
}
|
||||
|
||||
/** Sección "Receta": plantilla + texto editable + imprimir (para modales de visita/consulta) */
|
||||
export const RecetaSection: FC<RecetaSectionProps> = ({ value, onChange, onPrint }) => {
|
||||
const [recetas, setRecetas] = useState<Receta[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getRecetas()
|
||||
.then((res) => { if (res.status === 'success') setRecetas(res.recetas); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const aplicarPlantilla = (id: string) => {
|
||||
if (!id) return;
|
||||
const r = recetas.find((x) => String(x.id) === id);
|
||||
if (r) onChange(r.contenido);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="border-t border-theme-border pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-theme-heading">Receta</h4>
|
||||
<Button variant="outline" size="sm" onClick={onPrint} disabled={!value.trim()}>
|
||||
<Printer size={14} className="mr-1.5" />
|
||||
Imprimir receta
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
label="Usar plantilla"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona plantilla...' },
|
||||
...recetas.map((r) => ({ value: String(r.id), label: r.categoria ? `${r.name} (${r.categoria})` : r.name })),
|
||||
]}
|
||||
value=""
|
||||
onChange={(e) => aplicarPlantilla(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
label="Contenido de la receta"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Medicamentos, dosis e indicaciones..."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface RecetaPrintProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
paciente: string;
|
||||
doctor?: string | null;
|
||||
contenido: string;
|
||||
}
|
||||
|
||||
/** Vista de impresión de la receta médica (formato imprimible) */
|
||||
export const RecetaPrint: FC<RecetaPrintProps> = ({ isOpen, onClose, paciente, doctor, contenido }) => {
|
||||
const clinic = clinicSettings();
|
||||
const fecha = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vista de impresión — Receta médica"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cerrar</Button>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={16} className="mr-2" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="print-receta bg-white text-[#222] p-6 rounded-xl border border-theme-border">
|
||||
{/* Encabezado clínica */}
|
||||
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold">{clinic.name}</p>
|
||||
{clinic.address && <p className="text-xs text-[#555]">{clinic.address}</p>}
|
||||
{clinic.phone && <p className="text-xs text-[#555]">Tel: {clinic.phone}</p>}
|
||||
</div>
|
||||
<p className="text-sm text-[#555]">{fecha}</p>
|
||||
</div>
|
||||
|
||||
{/* Paciente */}
|
||||
<p className="text-sm mb-4">
|
||||
<span className="text-[#555]">Paciente: </span>
|
||||
<span className="font-semibold">{paciente}</span>
|
||||
</p>
|
||||
|
||||
{/* Cuerpo */}
|
||||
<div className="flex gap-4 min-h-[220px]">
|
||||
<p className="text-4xl font-bold text-[#1abc9c] leading-none select-none">℞</p>
|
||||
<p className="text-sm whitespace-pre-wrap flex-1">{contenido}</p>
|
||||
</div>
|
||||
|
||||
{/* Firma */}
|
||||
<div className="mt-10 text-center">
|
||||
<div className="inline-block border-t border-[#333] pt-1.5 px-8">
|
||||
<p className="text-sm font-semibold">{doctor || 'Médico tratante'}</p>
|
||||
<p className="text-xs text-[#555]">{clinic.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecetaPrint;
|
||||
210
frontend/src/components/RecetasPanel.tsx
Normal file
210
frontend/src/components/RecetasPanel.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, ScrollText } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
TextArea,
|
||||
Modal,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from './ui';
|
||||
import { odooApi, type Receta } from '../services/odoo';
|
||||
|
||||
const RecetasPanel: FC = () => {
|
||||
const [recetas, setRecetas] = useState<Receta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Receta | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', categoria: '', contenido: '' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getRecetas(search || undefined);
|
||||
if (res.status === 'success') setRecetas(res.recetas);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar recetas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ name: '', categoria: '', contenido: '' });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (r: Receta) => {
|
||||
setEditing(r);
|
||||
setForm({ name: r.name, categoria: r.categoria, contenido: r.contenido });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.contenido.trim()) {
|
||||
toast.error('Nombre y contenido son obligatorios');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateReceta(editing.id, form);
|
||||
toast.success('Receta actualizada');
|
||||
} else {
|
||||
await odooApi.createReceta(form);
|
||||
toast.success('Receta creada');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar receta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eliminar = async (r: Receta) => {
|
||||
if (!window.confirm(`¿Eliminar la receta "${r.name}"?`)) return;
|
||||
try {
|
||||
await odooApi.deleteReceta(r.id);
|
||||
toast.success('Receta eliminada');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al eliminar receta');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o categoría..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="sm:ml-auto">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva receta
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-12 w-full" />
|
||||
) : recetas.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin recetas"
|
||||
subtitle="Crea la primera plantilla de receta."
|
||||
actionLabel="Nueva receta"
|
||||
onAction={openCreate}
|
||||
icon={<ScrollText size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Categoría</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Contenido</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{recetas.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{r.name}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{r.categoria || '-'}</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
<span className="block max-w-md truncate">{r.contenido}</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(r)} title="Eliminar">
|
||||
<Trash2 size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{recetas.map((r) => (
|
||||
<MobileCard
|
||||
key={r.id}
|
||||
title={r.name}
|
||||
subtitle={r.categoria || undefined}
|
||||
rows={[{ label: 'Contenido', value: r.contenido.slice(0, 60) }]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={16} className="text-theme-muted" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(r)}><Trash2 size={16} className="text-rose-500" /></Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal crear/editar receta */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar receta' : 'Nueva receta'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nombre *"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="ej. Post-peeling, Antibiótico acné"
|
||||
/>
|
||||
<Input
|
||||
label="Categoría"
|
||||
value={form.categoria}
|
||||
onChange={(e) => setForm({ ...form, categoria: e.target.value })}
|
||||
placeholder="ej. Cuidados, Medicamento"
|
||||
/>
|
||||
<TextArea
|
||||
label="Contenido * (medicamentos, dosis, indicaciones)"
|
||||
value={form.contenido}
|
||||
onChange={(e) => setForm({ ...form, contenido: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecetasPanel;
|
||||
@@ -7,15 +7,13 @@ import {
|
||||
Users,
|
||||
Stethoscope,
|
||||
Briefcase,
|
||||
Package,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
Store,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
ChevronRight,
|
||||
@@ -24,12 +22,23 @@ import {
|
||||
Target,
|
||||
Cake,
|
||||
Boxes,
|
||||
ShieldCheck,
|
||||
Palette,
|
||||
ClipboardList,
|
||||
ClipboardPlus,
|
||||
Banknote,
|
||||
Percent,
|
||||
FolderClock,
|
||||
Star,
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -52,16 +61,20 @@ const menuGroups = [
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
{ 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: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Productos', icon: Package, href: '/productos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operaciones',
|
||||
items: [
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||
{ label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
|
||||
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
|
||||
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
|
||||
@@ -71,6 +84,7 @@ const menuGroups = [
|
||||
label: 'Analítica',
|
||||
items: [
|
||||
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
|
||||
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
|
||||
],
|
||||
},
|
||||
@@ -80,12 +94,6 @@ const menuGroups = [
|
||||
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administración',
|
||||
items: [
|
||||
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'WACRM',
|
||||
items: [
|
||||
@@ -99,7 +107,6 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { toggleTheme } = useTheme();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
@@ -110,6 +117,27 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
[canSee, user]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = useMemo(
|
||||
() => menuGroups.flatMap((g) => g.items).filter((it) => canSee(it.href)),
|
||||
[canSee]
|
||||
);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => {
|
||||
for (const g of menuGroups) {
|
||||
const found = g.items.find((it) => it.href === href);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
@@ -188,6 +216,62 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-5 px-3">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<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>
|
||||
<span className="flex items-center gap-1">
|
||||
<QuickLinksModePicker direction="down" align="right" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.setEditing(!quick.editing)}
|
||||
className="text-white/40 hover:text-white transition-colors"
|
||||
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
|
||||
>
|
||||
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{quickItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className="flex items-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 ${
|
||||
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>
|
||||
</Link>
|
||||
{quick.editing && (
|
||||
<span className="flex items-center gap-0.5 shrink-0">
|
||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
||||
className="text-white/40 hover:text-white disabled:opacity-30 transition-colors" title="Subir">
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
||||
className="text-white/40 hover:text-white disabled:opacity-30 transition-colors" title="Bajar">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.remove(item.href)}
|
||||
className="text-white/40 hover:text-rose-400 transition-colors" title="Quitar">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label} className="mb-6">
|
||||
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
|
||||
@@ -195,12 +279,13 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
const pinned = quick.isPinned(item.href);
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<li key={item.label} className="group flex items-center gap-1">
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
className={`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'
|
||||
@@ -210,6 +295,16 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<span>{item.label}</span>
|
||||
{active && <ChevronRight size={14} className="ml-auto" />}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.toggle(item.href)}
|
||||
className={`shrink-0 transition-all ${
|
||||
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
||||
}`}
|
||||
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
||||
>
|
||||
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -312,43 +407,21 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<Target size={14} className="mr-1.5" /> Definir meta
|
||||
</Button>
|
||||
)}
|
||||
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar paciente, cita..."
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 placeholder:text-theme-muted/60"
|
||||
style={{ color: 'var(--text-heading)' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title="Cambiar a HomeNest"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--surface)'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
HomeNest
|
||||
</button>
|
||||
<GlobalSearch className="hidden md:block" />
|
||||
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<main className={`flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8 ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<QuickLinksDock items={dockItems} />
|
||||
|
||||
<Modal
|
||||
isOpen={goalModalOpen}
|
||||
onClose={() => setGoalModalOpen(false)}
|
||||
|
||||
71
frontend/src/components/ThemePicker.tsx
Normal file
71
frontend/src/components/ThemePicker.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Palette, Check } from 'lucide-react';
|
||||
import { useTheme, type Theme } from '../lib/theme';
|
||||
|
||||
const THEME_LABELS: Record<Theme, string> = {
|
||||
skeen: 'SKEEN',
|
||||
homenest: 'HomeNest',
|
||||
clasico: 'Clásico',
|
||||
};
|
||||
|
||||
interface ThemePickerProps {
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export const ThemePicker: FC<ThemePickerProps> = ({ triggerClassName }) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Cambiar tema"
|
||||
className={
|
||||
triggerClassName ||
|
||||
'hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border border-theme-border bg-theme-surface text-theme-heading hover:bg-theme-accent-bg'
|
||||
}
|
||||
>
|
||||
<Palette size={14} />
|
||||
{THEME_LABELS[theme]}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 z-50 w-44 rounded-2xl border border-theme-border bg-theme-surface shadow-card p-1.5">
|
||||
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
|
||||
Tema
|
||||
</p>
|
||||
{(Object.keys(THEME_LABELS) as Theme[]).map((t) => {
|
||||
const selected = theme === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => { setTheme(t); setOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm transition-colors ${
|
||||
selected ? 'bg-theme-accent-bg text-theme-heading font-medium' : 'text-theme-muted hover:bg-theme-bg hover:text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
{THEME_LABELS[t]}
|
||||
{selected && <Check size={14} className="ml-auto" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemePicker;
|
||||
375
frontend/src/components/UsuariosPanel.tsx
Normal file
375
frontend/src/components/UsuariosPanel.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Pencil, KeyRound, ShieldCheck, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
Badge,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
MobileCard,
|
||||
toast,
|
||||
} from './ui';
|
||||
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
|
||||
import { ROLE_LABELS, useAuth } from '../lib/auth';
|
||||
import { MENU_ITEMS } from '../lib/menu-items';
|
||||
|
||||
const roleOptions: { value: FrontendRole; label: string }[] = (
|
||||
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
|
||||
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||
|
||||
const roleBadge = (role: FrontendRole) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'danger';
|
||||
case 'recepcion':
|
||||
return 'info';
|
||||
case 'medico':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const UsuariosPanel: FC = () => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<FrontendUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FrontendUser | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
|
||||
const [permUser, setPermUser] = useState<FrontendUser | null>(null);
|
||||
const [permCustom, setPermCustom] = useState(false);
|
||||
const [permSelected, setPermSelected] = useState<string[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.listFrontendUsers();
|
||||
if (res.status === 'success') setUsers(res.users);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar usuarios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (u: FrontendUser) => {
|
||||
setEditing(u);
|
||||
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!editing && !form.login.trim()) {
|
||||
toast.error('El usuario es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!editing && form.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
if (editing && form.password && form.password.length < 8) {
|
||||
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateFrontendUser(editing.id, {
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
toast.success('Usuario actualizado');
|
||||
} else {
|
||||
await odooApi.createFrontendUser({
|
||||
login: form.login.trim(),
|
||||
name: form.name.trim(),
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
});
|
||||
toast.success('Usuario creado');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar usuario');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (u: FrontendUser) => {
|
||||
try {
|
||||
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al cambiar estado');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const eliminar = async (u: FrontendUser) => {
|
||||
if (!window.confirm(`¿Eliminar al usuario "${u.login}"? Esta acción no se puede deshacer.`)) return;
|
||||
try {
|
||||
await odooApi.deleteFrontendUser(u.id);
|
||||
toast.success('Usuario eliminado');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al eliminar usuario');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const openPermisos = (u: FrontendUser) => {
|
||||
const custom = Array.isArray(u.allowed_menus) && u.allowed_menus.length > 0;
|
||||
setPermUser(u);
|
||||
setPermCustom(custom);
|
||||
setPermSelected(custom && u.allowed_menus ? u.allowed_menus : MENU_ITEMS.map((m) => m.href));
|
||||
};
|
||||
|
||||
const togglePerm = (href: string) => {
|
||||
setPermSelected((prev) => (prev.includes(href) ? prev.filter((h) => h !== href) : [...prev, href]));
|
||||
};
|
||||
|
||||
const savePermisos = async () => {
|
||||
if (!permUser) return;
|
||||
try {
|
||||
setPermSaving(true);
|
||||
await odooApi.updateFrontendUser(permUser.id, { allowed_menus: permCustom ? permSelected : [] });
|
||||
toast.success('Permisos actualizados (se aplican en el próximo inicio de sesión del usuario)');
|
||||
setPermUser(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al guardar permisos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const permWarning = permUser && currentUser && permUser.id === currentUser.id && permCustom && !permSelected.includes('/configuracion');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-theme-muted">Gestiona quién puede entrar al sistema, su rol y sus secciones visibles.</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-12 w-full" />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Usuario</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Secciones</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Último acceso</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-theme-bg">
|
||||
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
|
||||
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
|
||||
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
|
||||
{u.allowed_menus && u.allowed_menus.length > 0
|
||||
? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones`
|
||||
: 'Por rol'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{u.last_login || '—'}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openPermisos(u)} title="Permisos de menú">
|
||||
<ShieldCheck size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
|
||||
<Pencil size={16} className="text-theme-muted" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
|
||||
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-theme-heading'} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(u)} title="Eliminar">
|
||||
<Trash2 size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-3">
|
||||
{users.map((u) => (
|
||||
<MobileCard
|
||||
key={u.id}
|
||||
title={u.name}
|
||||
subtitle={u.login}
|
||||
rows={[
|
||||
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
|
||||
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
|
||||
{ label: 'Secciones', value: u.allowed_menus && u.allowed_menus.length > 0 ? `${u.allowed_menus.length} de ${MENU_ITEMS.length}` : 'Por rol' },
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openPermisos(u)}><ShieldCheck size={16} className="text-theme-muted" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-theme-muted" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => eliminar(u)}><Trash2 size={16} className="text-rose-500" /></Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal crear/editar usuario */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Usuario (login)"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
disabled={!!editing}
|
||||
placeholder="ej. recepcion1"
|
||||
/>
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Nombre completo"
|
||||
/>
|
||||
<Select
|
||||
label="Rol"
|
||||
options={roleOptions}
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
|
||||
/>
|
||||
<Input
|
||||
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
minLength={8}
|
||||
/>
|
||||
{editing && (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Usuario activo
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal permisos de menú */}
|
||||
<Modal
|
||||
isOpen={!!permUser}
|
||||
onClose={() => setPermUser(null)}
|
||||
title={permUser ? `Permisos — ${permUser.name}` : 'Permisos'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPermUser(null)}>Cancelar</Button>
|
||||
<Button onClick={savePermisos} loading={permSaving}>Guardar permisos</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!permCustom}
|
||||
onChange={(e) => setPermCustom(!e.target.checked)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Usar permisos por rol (default)
|
||||
</label>
|
||||
{permCustom && (
|
||||
<>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{permSelected.length} de {MENU_ITEMS.length} secciones visibles
|
||||
</p>
|
||||
{permWarning && (
|
||||
<div className="flex items-start gap-2 p-3 bg-theme-warning text-theme-warning-text rounded-xl text-xs">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<p>Estás por quitarte a ti mismo el acceso a Configuración. No podrás volver a abrir esta pantalla.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<label key={item.href} className="flex items-center gap-2 p-2.5 border border-theme-border rounded-lg cursor-pointer hover:bg-theme-bg text-sm text-theme-heading">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permSelected.includes(item.href)}
|
||||
onChange={() => togglePerm(item.href)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsuariosPanel;
|
||||
178
frontend/src/components/inventario/AlertasPanel.tsx
Normal file
178
frontend/src/components/inventario/AlertasPanel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CalendarX, ShoppingCart } from 'lucide-react';
|
||||
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
|
||||
const caducidadBadge = (row: CaducidadRow): { variant: BadgeVariant; label: string } => {
|
||||
if (row.vencido) return { variant: 'danger', label: `Vencido hace ${Math.abs(row.dias_restantes)} días` };
|
||||
if (row.dias_restantes <= 30) return { variant: 'warning', label: `${row.dias_restantes} días` };
|
||||
return { variant: 'info', label: `${row.dias_restantes} días` };
|
||||
};
|
||||
|
||||
const AlertasPanel: FC = () => {
|
||||
const [caducidades, setCaducidades] = useState<CaducidadRow[]>([]);
|
||||
const [sugerencia, setSugerencia] = useState<SugerenciaRow[]>([]);
|
||||
const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getInventarioAlertas();
|
||||
if (res.status === 'success') {
|
||||
setCaducidades(res.caducidades);
|
||||
setSugerencia(res.sugerencia_compra);
|
||||
setTotalSugerencia(res.sugerencia_total);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar alertas');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `sugerencia-compra-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Sugerencia de compra',
|
||||
title: 'Sugerencia de compra — inventario SKEEN',
|
||||
subtitle: `${totalSugerencia.items} artículos bajo mínimo · costo estimado ${fmtMoney(totalSugerencia.costo_total)}`,
|
||||
columns: [
|
||||
{ header: 'Artículo', key: 'name' },
|
||||
{ header: 'Categoría', key: 'category' },
|
||||
{ header: 'Unidad', key: 'unit' },
|
||||
{ header: 'Existencia', key: 'qty', format: 'number' },
|
||||
{ header: 'Mínimo', key: 'qty_min', format: 'number' },
|
||||
{ header: 'Óptimo', key: 'qty_optimal', format: 'number' },
|
||||
{ header: 'Sugerido', key: 'sugerido', format: 'number' },
|
||||
{ header: 'Costo estimado', key: 'costo_estimado', format: 'currency' },
|
||||
],
|
||||
rows: sugerencia.map((s) => ({ ...s })),
|
||||
totals: { name: '', costo_estimado: totalSugerencia.costo_total },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Caducidades */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||
<CalendarX size={20} className="mr-2" /> Caducidades
|
||||
</h3>
|
||||
<p className="text-xs text-theme-muted mb-4">Artículos con fecha de caducidad registrada, ordenados por urgencia.</p>
|
||||
{loading ? (
|
||||
<Skeleton count={4} className="h-10 w-full" />
|
||||
) : caducidades.length === 0 ? (
|
||||
<EmptyState title="Sin caducidades" subtitle="Ningún artículo tiene fecha de caducidad registrada." icon={<CalendarX size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Categoría</th>
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-left px-4 py-3">Caduca</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{caducidades.map((c) => {
|
||||
const b = caducidadBadge(c);
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.category || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.qty} {c.unit}</td>
|
||||
<td className="px-4 py-3 text-theme-heading">{c.expiry_date}</td>
|
||||
<td className="px-4 py-3"><Badge variant={b.variant}>{b.label}</Badge></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Sugerencia de compra */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h3 className="font-heading text-xl text-theme-heading flex items-center">
|
||||
<ShoppingCart size={20} className="mr-2" /> Sugerencia de compra
|
||||
</h3>
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mb-4">
|
||||
Artículos bajo su stock mínimo (o sin existencia). Sugerido = óptimo − existencia (o 2× mínimo).
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-10 w-full" />
|
||||
) : sugerencia.length === 0 ? (
|
||||
<EmptyState title="Sin faltantes" subtitle="Todos los artículos están por encima de su mínimo." icon={<ShoppingCart size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
|
||||
<th className="text-right px-4 py-3">Sugerido</th>
|
||||
<th className="text-right px-4 py-3">Costo est.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{sugerencia.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{s.name}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{s.qty} {s.unit}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{s.qty_min || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{s.qty_optimal || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">+{s.sugerido} {s.unit}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(s.costo_estimado)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex justify-end mt-3 pt-3 border-t border-theme-border">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total estimado: <span className="font-semibold text-theme-heading">{fmtMoney(totalSugerencia.costo_total)}</span>
|
||||
{' '}({totalSugerencia.items} artículos)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertasPanel;
|
||||
255
frontend/src/components/inventario/MovimientosPanel.tsx
Normal file
255
frontend/src/components/inventario/MovimientosPanel.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../ui';
|
||||
import { odooApi, type MovimientoRow, type InventoryItem } from '../../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
interface MovimientosPanelProps {
|
||||
tipo: 'compra' | 'baja';
|
||||
}
|
||||
|
||||
const MovimientosPanel: FC<MovimientosPanelProps> = ({ tipo }) => {
|
||||
const esCompra = tipo === 'compra';
|
||||
const [movimientos, setMovimientos] = useState<MovimientoRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [itemSearch, setItemSearch] = useState('');
|
||||
const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = { type: tipo, page, page_size: 50 };
|
||||
if (search) params.search = search;
|
||||
const res = await odooApi.getMovimientos(params);
|
||||
if (res.status === 'success') {
|
||||
setMovimientos(res.movimientos);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar movimientos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tipo, search, page]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [search, tipo]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Catálogo de artículos para el select del modal
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return;
|
||||
odooApi.getInventarioItems(itemSearch || undefined)
|
||||
.then((res) => { if (res.status === 'success') setItems(res.items); })
|
||||
.catch(() => {});
|
||||
}, [modalOpen, itemSearch]);
|
||||
|
||||
const openModal = () => {
|
||||
setForm({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
|
||||
setItemSearch('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const guardar = async () => {
|
||||
const qty = parseFloat(form.qty);
|
||||
if (!form.item_id) {
|
||||
toast.error('Selecciona un artículo');
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(qty) || qty <= 0) {
|
||||
toast.error('La cantidad debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
item_id: parseInt(form.item_id, 10),
|
||||
qty,
|
||||
reference: form.reference,
|
||||
notes: form.notes,
|
||||
...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}),
|
||||
};
|
||||
const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload);
|
||||
if (res.status === 'success') {
|
||||
toast.success(esCompra ? 'Compra registrada' : 'Baja registrada');
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al registrar el movimiento');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1 sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por artículo..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={openModal} className="sm:ml-auto">
|
||||
<Plus size={16} className="mr-2" />
|
||||
{esCompra ? 'Registrar compra' : 'Registrar baja'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-10 w-full" />
|
||||
) : movimientos.length === 0 ? (
|
||||
<EmptyState
|
||||
title={esCompra ? 'Sin compras' : 'Sin bajas'}
|
||||
subtitle={esCompra ? 'No hay compras registradas.' : 'No hay bajas/mermas registradas.'}
|
||||
actionLabel={esCompra ? 'Registrar compra' : 'Registrar baja'}
|
||||
onAction={openModal}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Fecha</th>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-right px-4 py-3">Cantidad</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Antes</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Después</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">{esCompra ? 'Referencia' : 'Motivo / Referencia'}</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Notas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{movimientos.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">{m.date || '-'}</td>
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{m.item}</td>
|
||||
<td className={`px-4 py-3 text-right font-medium ${esCompra ? 'text-theme-heading' : 'text-rose-600'}`}>
|
||||
{esCompra ? '+' : '−'}{m.qty} {m.unit}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{m.before_qty}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{m.after_qty}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{m.reference || '-'}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">
|
||||
<span className="block max-w-[200px] truncate">{m.notes || '-'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-theme-border">
|
||||
<p className="text-sm text-theme-muted">{total} movimientos · página {page} de {totalPages || 1}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Anterior</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Siguiente</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Modal registrar movimiento */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Buscar artículo..."
|
||||
value={itemSearch}
|
||||
onChange={(e) => setItemSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Artículo *"
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona artículo...' },
|
||||
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
|
||||
]}
|
||||
value={form.item_id}
|
||||
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad *"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
value={form.qty}
|
||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
{esCompra && (
|
||||
<Input
|
||||
label="Costo unitario (actualiza el costo del artículo)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.cost}
|
||||
onChange={(e) => setForm({ ...form, cost: e.target.value })}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label={esCompra ? 'Notas' : 'Motivo'}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
{esCompra && form.cost !== '' && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovimientosPanel;
|
||||
130
frontend/src/components/reportes/AdeudosReport.tsx
Normal file
130
frontend/src/components/reportes/AdeudosReport.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HandCoins } from 'lucide-react';
|
||||
import { Card, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type AdeudoRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const AdeudosReport: FC = () => {
|
||||
const [adeudos, setAdeudos] = useState<AdeudoRow[]>([]);
|
||||
const [totalCartera, setTotalCartera] = useState(0);
|
||||
const [totalPacientes, setTotalPacientes] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getAdeudos();
|
||||
if (res.status === 'success') {
|
||||
setAdeudos(res.adeudos);
|
||||
setTotalCartera(res.total_cartera);
|
||||
setTotalPacientes(res.total_pacientes);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar adeudos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `adeudos-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Adeudos',
|
||||
title: 'Adeudos de clientes',
|
||||
subtitle: `${totalPacientes} pacientes con adeudo`,
|
||||
columns: [
|
||||
{ header: 'Paciente', key: 'name' },
|
||||
{ header: 'Teléfono', key: 'phone' },
|
||||
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
|
||||
{ header: 'Adeudo', key: 'total_adeudo', format: 'currency' },
|
||||
{ header: 'Última venta', key: 'ultima_venta', format: 'date' },
|
||||
],
|
||||
rows: adeudos.map((a) => ({ ...a })),
|
||||
totals: { name: '', total_adeudo: totalCartera, num_ventas: adeudos.reduce((a, x) => a + x.num_ventas, 0) },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Total cartera por cobrar</p>
|
||||
<p className={`text-xl font-heading font-semibold ${totalCartera > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{formatCurrency(totalCartera)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Pacientes con adeudo</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{totalPacientes}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-10 w-full" />
|
||||
) : adeudos.length === 0 ? (
|
||||
<EmptyState title="Sin adeudos" subtitle="Ningún paciente tiene saldo pendiente." icon={<HandCoins size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Teléfono</th>
|
||||
<th className="text-right px-4 py-3"># Ventas</th>
|
||||
<th className="text-right px-4 py-3">Adeudo</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Última venta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{adeudos.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">
|
||||
{a.phone ? (
|
||||
<Link to={`/pacientes?q=${encodeURIComponent(a.phone)}`} className="hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
) : (
|
||||
a.name
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{a.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{a.num_ventas}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-rose-600">{formatCurrency(a.total_adeudo)}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{a.ultima_venta || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdeudosReport;
|
||||
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal file
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Stethoscope } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type ComisionRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const ComisionesReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [rows, setRows] = useState<ComisionRow[]>([]);
|
||||
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getComisiones(start, end);
|
||||
if (res.status === 'success') {
|
||||
setRows(res.comisiones);
|
||||
setTotales({ vendido: res.total_vendido, comision: res.total_comision });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar comisiones');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `comisiones-${start}_${end}`,
|
||||
sheetName: 'Comisiones',
|
||||
title: 'Comisiones por médico',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Médico', key: 'doctor' },
|
||||
{ header: 'Puesto', key: 'job_title' },
|
||||
{ header: 'Citas completadas', key: 'citas_done', format: 'number' },
|
||||
{ header: 'Líneas', key: 'items', format: 'number' },
|
||||
{ header: 'Ventas', key: 'sales', format: 'number' },
|
||||
{ header: 'Total vendido', key: 'total_vendido', format: 'currency' },
|
||||
{ header: '% Comisión', key: 'commission_pct', format: 'number' },
|
||||
{ header: 'Comisión estimada', key: 'comision', format: 'currency' },
|
||||
],
|
||||
rows: rows.map((r) => ({ ...r })),
|
||||
totals: { doctor: '', total_vendido: totales.vendido, comision: totales.comision },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Vendido: <span className="font-semibold text-theme-heading">{formatCurrency(totales.vendido)}</span>
|
||||
{' · '}Comisiones: <span className="font-semibold text-theme-heading">{formatCurrency(totales.comision)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin comisiones"
|
||||
subtitle="No hay artículos recetados por médicos en el periodo. Marca las líneas como “Artículo recetado” al crear la venta."
|
||||
icon={<Stethoscope size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">Citas completadas</th>
|
||||
<th className="text-right px-4 py-3">Líneas</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Ventas</th>
|
||||
<th className="text-right px-4 py-3">Total vendido</th>
|
||||
<th className="text-right px-4 py-3">% Comisión</th>
|
||||
<th className="text-right px-4 py-3">Comisión estimada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.doctor_id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{r.doctor}</p>
|
||||
<p className="text-xs text-theme-muted">{r.job_title || '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.citas_done}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.items}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{r.sales}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(r.total_vendido)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.commission_pct}%</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-theme-heading">{formatCurrency(r.comision)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComisionesReport;
|
||||
191
frontend/src/components/reportes/ConcentradoReport.tsx
Normal file
191
frontend/src/components/reportes/ConcentradoReport.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { Card, Input, Badge, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const citaStateLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
|
||||
};
|
||||
const metodoLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
const stateBadge = (state: string): BadgeVariant =>
|
||||
state === 'done' || state === 'completada' ? 'success'
|
||||
: state === 'cancelled' || state === 'no_show' || state === 'cancelada' ? 'danger'
|
||||
: state === 'pending' || state === 'en_curso' ? 'warning' : 'info';
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ label, value, rose }) => (
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">{label}</p>
|
||||
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const ConcentradoReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [data, setData] = useState<ConcentradoData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getConcentrado(start, end);
|
||||
if (res.status === 'success') {
|
||||
const { citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e } = res;
|
||||
setData({ citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar concentrado');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [
|
||||
{ seccion: 'Citas', concepto: 'Total', cantidad: data.citas.total, monto: null },
|
||||
...Object.entries(data.citas.by_state).map(([k, v]) => ({ seccion: 'Citas', concepto: citaStateLabels[k] || k, cantidad: v, monto: null })),
|
||||
{ seccion: 'Visitas', concepto: 'Total', cantidad: data.visitas.total, monto: null },
|
||||
...Object.entries(data.visitas.by_state).map(([k, v]) => ({ seccion: 'Visitas', concepto: visitaStateLabels[k] || k, cantidad: v, monto: null })),
|
||||
{ seccion: 'Ventas', concepto: 'Cantidad', cantidad: data.ventas.count, monto: null },
|
||||
{ seccion: 'Ventas', concepto: 'Total vendido', cantidad: null, monto: data.ventas.total },
|
||||
{ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: data.ventas.total_due },
|
||||
{ seccion: 'Cobros', concepto: 'Total cobrado', cantidad: data.pagos.count, monto: data.pagos.total },
|
||||
...Object.entries(data.pagos.by_method).map(([k, v]) => ({ seccion: 'Cobros', concepto: metodoLabels[k] || k, cantidad: null, monto: v })),
|
||||
{ seccion: 'Devoluciones', concepto: 'Devuelto', cantidad: data.devoluciones.count, monto: data.devoluciones.total },
|
||||
{ seccion: 'Pacientes', concepto: 'Nuevos en el periodo', cantidad: data.pacientes_nuevos, monto: null },
|
||||
];
|
||||
await exportToExcel({
|
||||
filename: `concentrado-${start}_${end}`,
|
||||
sheetName: 'Concentrado',
|
||||
title: 'Concentrado del periodo',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Sección', key: 'seccion' },
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
|
||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Kpi label="Citas" value={data.citas.total} />
|
||||
<Kpi label="Visitas clínicas" value={data.visitas.total} />
|
||||
<Kpi label="Pacientes nuevos" value={data.pacientes_nuevos} />
|
||||
<Kpi label="Ventas" value={data.ventas.count} />
|
||||
<Kpi label="Total vendido" value={formatCurrency(data.ventas.total)} />
|
||||
<Kpi label="Cobrado" value={formatCurrency(data.pagos.total)} />
|
||||
<Kpi label="Por cobrar" value={formatCurrency(data.ventas.total_due)} rose={data.ventas.total_due > 0} />
|
||||
<Kpi label="Devoluciones" value={`${data.devoluciones.count} · ${formatCurrency(data.devoluciones.total)}`} rose={data.devoluciones.total > 0} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3 flex items-center">
|
||||
<LayoutGrid size={18} className="mr-2" /> Citas por estado
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.citas.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant={stateBadge(state)}>
|
||||
{citaStateLabels[state] || state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
{data.citas.total === 0 && <p className="text-sm text-theme-muted">Sin citas.</p>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">Cobros por método</h3>
|
||||
{Object.keys(data.pagos.by_method).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin cobros.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(data.pagos.by_method).map(([method, amount]) => (
|
||||
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
|
||||
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">Visitas por estado</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.visitas.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant={stateBadge(state)}>
|
||||
{visitaStateLabels[state] || state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
{data.visitas.total === 0 && <p className="text-sm text-theme-muted">Sin visitas.</p>}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConcentradoReport;
|
||||
154
frontend/src/components/reportes/CortesReport.tsx
Normal file
154
frontend/src/components/reportes/CortesReport.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Receipt, ExternalLink } from 'lucide-react';
|
||||
import { Card, Button, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type CashClosing } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const PAGE = 20;
|
||||
|
||||
const CortesReport: FC = () => {
|
||||
const [cortes, setCortes] = useState<CashClosing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getCashClosings();
|
||||
if (res.status === 'success') setCortes(res.cash_closings);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar cortes de caja');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const money = { format: 'currency' as const };
|
||||
await exportToExcel({
|
||||
filename: `cortes-de-caja-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Cortes de caja',
|
||||
title: 'Cortes de caja',
|
||||
subtitle: `${cortes.length} cortes`,
|
||||
columns: [
|
||||
{ header: 'Fecha', key: 'date', format: 'date' },
|
||||
{ header: 'Usuario', key: 'user' },
|
||||
{ header: 'Apertura', key: 'opening_cash', ...money },
|
||||
{ header: 'Efectivo', key: 'total_cash', ...money },
|
||||
{ header: 'Tarjeta', key: 'total_card', ...money },
|
||||
{ header: 'Transferencia', key: 'total_transfer', ...money },
|
||||
{ header: 'Otros', key: 'total_other', ...money },
|
||||
{ header: 'Total ventas', key: 'total_sales', ...money },
|
||||
{ header: 'Diferencia', key: 'difference', ...money },
|
||||
{ header: 'Estado', key: 'estado' },
|
||||
],
|
||||
rows: cortes.map((c) => ({ ...c, estado: c.state === 'closed' ? 'Cerrado' : 'Abierto' })),
|
||||
totals: {
|
||||
date: '', user: '', opening_cash: cortes.reduce((a, c) => a + c.opening_cash, 0),
|
||||
total_cash: cortes.reduce((a, c) => a + c.total_cash, 0),
|
||||
total_card: cortes.reduce((a, c) => a + c.total_card, 0),
|
||||
total_transfer: cortes.reduce((a, c) => a + c.total_transfer, 0),
|
||||
total_other: cortes.reduce((a, c) => a + c.total_other, 0),
|
||||
total_sales: cortes.reduce((a, c) => a + c.total_sales, 0),
|
||||
},
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visibles = verMas ? cortes : cortes.slice(0, PAGE);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-heading text-xl text-theme-heading">Cortes de caja</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
<Link to="/cortes">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
Ir a Cortes
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : cortes.length === 0 ? (
|
||||
<EmptyState title="Sin cortes" subtitle="Aún no hay cortes de caja registrados." icon={<Receipt size={28} />} />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Fecha</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Usuario</th>
|
||||
<th className="text-right px-4 py-3">Apertura</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Efectivo</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Tarjeta</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Transfer.</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Otros</th>
|
||||
<th className="text-right px-4 py-3">Total ventas</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Diferencia</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{visibles.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 text-theme-heading whitespace-nowrap">{c.date}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{c.user || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(c.opening_cash)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(c.total_cash)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(c.total_card)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{formatCurrency(c.total_transfer)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{formatCurrency(c.total_other)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total_sales)}</td>
|
||||
<td className={`px-4 py-3 text-right hidden sm:table-cell ${c.difference ? 'text-rose-600' : 'text-theme-muted'}`}>
|
||||
{c.state === 'closed' ? formatCurrency(c.difference) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={c.state === 'closed' ? 'success' : 'warning'}>
|
||||
{c.state === 'closed' ? 'Cerrado' : 'Abierto'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!verMas && cortes.length > PAGE && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" size="sm" onClick={() => setVerMas(true)}>
|
||||
Ver más ({cortes.length - PAGE} restantes)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CortesReport;
|
||||
287
frontend/src/components/reportes/DailyReport.tsx
Normal file
287
frontend/src/components/reportes/DailyReport.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Calendar, ShoppingCart, CreditCard, Boxes, Stethoscope } from 'lucide-react';
|
||||
import { Card, Input, Badge, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type DailyReport as DailyReportData, type DailyPorMedico } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const citaStateLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
|
||||
const citaStateBadge = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'done': return 'success';
|
||||
case 'cancelled': case 'no_show': return 'danger';
|
||||
case 'pending': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const metodoLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
|
||||
const moveTypeLabels: Record<string, string> = {
|
||||
compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste',
|
||||
};
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const DailyReport: FC = () => {
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
const [report, setReport] = useState<DailyReportData | null>(null);
|
||||
const [porMedico, setPorMedico] = useState<DailyPorMedico[] | null>(null);
|
||||
const [verPorMedico, setVerPorMedico] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getDailyReport(fecha, verPorMedico ? 'medico' : undefined);
|
||||
if (res.status === 'success') {
|
||||
const { citas, ventas, pagos, inventario, date } = res;
|
||||
setReport({ citas, ventas, pagos, inventario, date });
|
||||
setPorMedico(res.por_medico || null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar movimientos del día');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fecha, verPorMedico]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
if (!report) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
Object.entries(report.citas.by_state).forEach(([k, v]) =>
|
||||
rows.push({ seccion: 'Citas', concepto: citaStateLabels[k] || k, cantidad: v, monto: null }));
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Cantidad', cantidad: report.ventas.count, monto: null });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Total', cantidad: null, monto: report.ventas.total });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Pagado', cantidad: null, monto: report.ventas.total_paid });
|
||||
rows.push({ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: report.ventas.total_due });
|
||||
Object.entries(report.pagos.by_method).forEach(([k, v]) =>
|
||||
rows.push({ seccion: 'Cobros', concepto: metodoLabels[k] || k, cantidad: null, monto: v }));
|
||||
Object.entries(report.inventario.by_type).forEach(([k, v]) =>
|
||||
rows.push({ seccion: 'Inventario', concepto: moveTypeLabels[k] || k, cantidad: v, monto: null }));
|
||||
if (porMedico) {
|
||||
porMedico.forEach((r) =>
|
||||
rows.push({ seccion: 'Por médico', concepto: r.medico, cantidad: r.citas, monto: r.ventas + r.cobros }));
|
||||
}
|
||||
await exportToExcel({
|
||||
filename: `movimientos-diarios-${fecha}`,
|
||||
sheetName: 'Movimientos diarios',
|
||||
title: 'Movimientos diarios',
|
||||
subtitle: `Fecha: ${fecha}${verPorMedico ? ' · desglose por médico' : ''}`,
|
||||
columns: [
|
||||
{ header: 'Sección', key: 'seccion' },
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
|
||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||
],
|
||||
rows,
|
||||
totals: { seccion: '', concepto: 'Cobros del día', cantidad: report.pagos.count, monto: report.pagos.total },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-end gap-3">
|
||||
<Input
|
||||
label="Fecha"
|
||||
type="date"
|
||||
value={fecha}
|
||||
onChange={(e) => setFecha(e.target.value)}
|
||||
className="sm:max-w-[200px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVerPorMedico((v) => !v)}
|
||||
className={`mb-1 px-3 py-2 text-sm font-medium rounded-full transition flex items-center gap-1.5 ${
|
||||
verPorMedico ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
<Stethoscope size={14} />
|
||||
Por médico
|
||||
</button>
|
||||
<div className="ml-auto mb-1">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{verPorMedico ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Movimientos del día por médico</h3>
|
||||
{loading ? (
|
||||
<Skeleton count={5} className="h-12 w-full" />
|
||||
) : !porMedico || porMedico.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin actividad por médico este día.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">Citas</th>
|
||||
<th className="text-left px-4 py-3">Estados</th>
|
||||
<th className="text-right px-4 py-3">Ventas (recetado)</th>
|
||||
<th className="text-right px-4 py-3">Cobros</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{porMedico.map((r) => (
|
||||
<tr key={r.medico} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{r.medico}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.citas}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(r.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant={citaStateBadge(state)}>
|
||||
{citaStateLabels[state] || state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
{r.citas === 0 && <span className="text-theme-muted">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(r.ventas)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(r.cobros)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}
|
||||
</div>
|
||||
) : report ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Citas del día */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||
<Calendar size={20} className="mr-2" /> Citas del día
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mb-4">Total: <span className="font-semibold text-theme-heading">{report.citas.total}</span></p>
|
||||
{Object.keys(report.citas.by_state).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin citas este día.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(report.citas.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant={citaStateBadge(state)}>
|
||||
{citaStateLabels[state] || state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Ventas del día */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
|
||||
<ShoppingCart size={20} className="mr-2" /> Ventas del día
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Cantidad</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{report.ventas.count}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Total</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{formatCurrency(report.ventas.total)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Pagado</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{formatCurrency(report.ventas.total_paid)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Por cobrar</p>
|
||||
<p className={`text-lg font-semibold ${report.ventas.total_due > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{formatCurrency(report.ventas.total_due)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Cobros del día */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||
<CreditCard size={20} className="mr-2" /> Cobros del día
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mb-4">
|
||||
{report.pagos.count} cobros · Total: <span className="font-semibold text-theme-heading">{formatCurrency(report.pagos.total)}</span>
|
||||
</p>
|
||||
{Object.keys(report.pagos.by_method).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin cobros este día.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(report.pagos.by_method).map(([method, amount]) => (
|
||||
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
|
||||
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Movimientos de inventario */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
|
||||
<Boxes size={20} className="mr-2" /> Inventario del día
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mb-4">Movimientos: <span className="font-semibold text-theme-heading">{report.inventario.total}</span></p>
|
||||
{Object.keys(report.inventario.by_type).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin movimientos este día.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(report.inventario.by_type).map(([type, count]) => (
|
||||
<Badge key={type} variant="default">
|
||||
{moveTypeLabels[type] || type}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyReport;
|
||||
129
frontend/src/components/reportes/DevolucionesReport.tsx
Normal file
129
frontend/src/components/reportes/DevolucionesReport.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type DevolucionRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const DevolucionesReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [devoluciones, setDevoluciones] = useState<DevolucionRow[]>([]);
|
||||
const [totalDevuelto, setTotalDevuelto] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getDevoluciones(start, end);
|
||||
if (res.status === 'success') {
|
||||
setDevoluciones(res.devoluciones);
|
||||
setTotalDevuelto(res.total_devuelto);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar devoluciones');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `devoluciones-${start}_${end}`,
|
||||
sheetName: 'Devoluciones',
|
||||
title: 'Devoluciones',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Folio', key: 'folio' },
|
||||
{ header: 'Paciente', key: 'paciente' },
|
||||
{ header: 'Fecha venta', key: 'fecha', format: 'date' },
|
||||
{ header: 'Total venta', key: 'total', format: 'currency' },
|
||||
{ header: 'Devuelto', key: 'refund_amount', format: 'currency' },
|
||||
{ header: 'Motivo', key: 'refund_reason' },
|
||||
{ header: 'Fecha devolución', key: 'refunded_at' },
|
||||
],
|
||||
rows: devoluciones.map((d) => ({ ...d })),
|
||||
totals: { folio: '', refund_amount: totalDevuelto, total: devoluciones.reduce((a, d) => a + d.total, 0) },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total devuelto: <span className={`font-semibold ${totalDevuelto > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>{formatCurrency(totalDevuelto)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : devoluciones.length === 0 ? (
|
||||
<EmptyState title="Sin devoluciones" subtitle="No hay ventas devueltas en el periodo." icon={<RotateCcw size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Folio</th>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Fecha venta</th>
|
||||
<th className="text-right px-4 py-3">Total venta</th>
|
||||
<th className="text-right px-4 py-3">Devuelto</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Motivo</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Fecha devolución</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{devoluciones.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{d.folio}</td>
|
||||
<td className="px-4 py-3 text-theme-heading">{d.paciente}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{d.fecha || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(d.total)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-rose-600">{formatCurrency(d.refund_amount)}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">
|
||||
<span className="block max-w-[220px] truncate" title={d.refund_reason}>{d.refund_reason || '-'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{d.refunded_at || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevolucionesReport;
|
||||
149
frontend/src/components/reportes/ExportarReport.tsx
Normal file
149
frontend/src/components/reportes/ExportarReport.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Download, Mail, Star, Tag } from 'lucide-react';
|
||||
import { Card, Button, toast } from '../ui';
|
||||
import { odooApi, type Patient } from '../../services/odoo';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const ExportarReport: FC = () => {
|
||||
const [descargando, setDescargando] = useState<string | null>(null);
|
||||
|
||||
const fetchAllPatients = async (): Promise<Patient[]> => {
|
||||
const all: Patient[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const res = await odooApi.getPatients({ page, page_size: PAGE_SIZE });
|
||||
if (res.status !== 'success') break;
|
||||
all.push(...res.patients);
|
||||
totalPages = res.total_pages || 1;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
return all;
|
||||
};
|
||||
|
||||
const exportCorreos = async (soloVip: boolean) => {
|
||||
const key = soloVip ? 'vip' : 'correos';
|
||||
try {
|
||||
setDescargando(key);
|
||||
const pacientes = await fetchAllPatients();
|
||||
const filtrados = pacientes
|
||||
.filter((p) => p.email && (!soloVip || p.is_vip))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (filtrados.length === 0) {
|
||||
toast.error('No hay pacientes con email' + (soloVip ? ' VIP' : ''));
|
||||
return;
|
||||
}
|
||||
await exportToExcel({
|
||||
filename: `${soloVip ? 'correos-vip' : 'correos'}-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: soloVip ? 'Correos VIP' : 'Correos',
|
||||
title: soloVip ? 'Correos de pacientes VIP' : 'Correos de pacientes',
|
||||
subtitle: `${filtrados.length} correos`,
|
||||
columns: [
|
||||
{ header: 'Nombre', key: 'name' },
|
||||
{ header: 'Email', key: 'email' },
|
||||
{ header: 'Teléfono', key: 'phone' },
|
||||
],
|
||||
rows: filtrados.map((p) => ({ name: p.name, email: p.email, phone: p.phone })),
|
||||
});
|
||||
toast.success(`Excel descargado (${filtrados.length} correos)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar correos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setDescargando(null);
|
||||
}
|
||||
};
|
||||
|
||||
const exportPrecios = async () => {
|
||||
try {
|
||||
setDescargando('precios');
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
do {
|
||||
const res = await odooApi.getServices({ page, page_size: PAGE_SIZE });
|
||||
if (res.status !== 'success') break;
|
||||
res.services.forEach((s) => rows.push({
|
||||
code: s.code, name: s.name, category: s.category, price: s.price,
|
||||
package_price: s.package_price || 0, package_sessions: s.package_sessions || 0,
|
||||
}));
|
||||
totalPages = res.total_pages || 1;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
await exportToExcel({
|
||||
filename: `lista-precios-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Lista de precios',
|
||||
title: 'Lista de precios de servicios',
|
||||
subtitle: `${rows.length} servicios activos`,
|
||||
columns: [
|
||||
{ header: 'Código', key: 'code' },
|
||||
{ header: 'Nombre', key: 'name' },
|
||||
{ header: 'Categoría', key: 'category' },
|
||||
{ header: 'Precio', key: 'price', format: 'currency' },
|
||||
{ header: 'Precio paquete', key: 'package_price', format: 'currency' },
|
||||
{ header: 'Sesiones paquete', key: 'package_sessions', format: 'number' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
toast.success(`Excel descargado (${rows.length} servicios)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar lista de precios');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setDescargando(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: 'correos',
|
||||
icon: Mail,
|
||||
titulo: 'Exportar correos',
|
||||
descripcion: 'Todos los pacientes con email registrado (nombre, email, teléfono).',
|
||||
onClick: () => exportCorreos(false),
|
||||
},
|
||||
{
|
||||
key: 'vip',
|
||||
icon: Star,
|
||||
titulo: 'Correos VIP',
|
||||
descripcion: 'Solo pacientes marcados como VIP con email registrado.',
|
||||
onClick: () => exportCorreos(true),
|
||||
},
|
||||
{
|
||||
key: 'precios',
|
||||
icon: Tag,
|
||||
titulo: 'Lista de precios',
|
||||
descripcion: 'Servicios activos con código, categoría, precio y precio de paquete.',
|
||||
onClick: exportPrecios,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
{cards.map(({ key, icon: Icon, titulo, descripcion, onClick }) => (
|
||||
<Card key={key}>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-theme-accent-bg flex items-center justify-center text-theme-heading">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">{titulo}</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">{descripcion}</p>
|
||||
</div>
|
||||
<Button onClick={onClick} loading={descargando === key} disabled={!!descargando}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Descargar CSV
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportarReport;
|
||||
162
frontend/src/components/reportes/HorasAgendaReport.tsx
Normal file
162
frontend/src/components/reportes/HorasAgendaReport.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, Input, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type HoraAgendaRow } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const citaStateLabels: Record<string, string> = {
|
||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
|
||||
const citaStateBadge = (state: string): BadgeVariant => {
|
||||
switch (state) {
|
||||
case 'done': return 'success';
|
||||
case 'cancelled': case 'no_show': return 'danger';
|
||||
case 'pending': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const HorasAgendaReport: FC = () => {
|
||||
const [start, setStart] = useState(hoy());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [horas, setHoras] = useState<HoraAgendaRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getHorasAgenda(start, end);
|
||||
if (res.status === 'success') setHoras(res.horas);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar horas de agenda');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
horas.forEach((h) => {
|
||||
Object.entries(h.by_state).forEach(([state, count]) =>
|
||||
rows.push({ hora: h.hora, estado: citaStateLabels[state] || state, citas: count }));
|
||||
});
|
||||
await exportToExcel({
|
||||
filename: `horas-agenda-${start}_${end}`,
|
||||
sheetName: 'Horas agenda',
|
||||
title: 'Horas agenda',
|
||||
subtitle: `Del ${start} al ${end} · ${total} citas`,
|
||||
columns: [
|
||||
{ header: 'Hora', key: 'hora' },
|
||||
{ header: 'Estado', key: 'estado' },
|
||||
{ header: 'Citas', key: 'citas', format: 'number' },
|
||||
],
|
||||
rows,
|
||||
totals: { hora: '', estado: 'Total citas', citas: total },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = horas.map((h) => ({ name: h.hora, citas: h.total }));
|
||||
const total = horas.reduce((acc, h) => acc + h.total, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
<p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por hora del día</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[260px] w-full" />
|
||||
) : total === 0 ? (
|
||||
<EmptyState title="Sin citas" subtitle="No hay citas en el periodo." icon={<Clock size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
<Bar dataKey="citas" fill="#57534e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{!loading && total > 0 && (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Hora</th>
|
||||
<th className="text-right px-4 py-3">Citas</th>
|
||||
<th className="text-left px-4 py-3">Estados</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{horas.filter((h) => h.total > 0).map((h) => (
|
||||
<tr key={h.hora} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{h.hora}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{h.total}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(h.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant={citaStateBadge(state)}>
|
||||
{citaStateLabels[state] || state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorasAgendaReport;
|
||||
209
frontend/src/components/reportes/IngresosReport.tsx
Normal file
209
frontend/src/components/reportes/IngresosReport.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const metodoLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
interface SalesData {
|
||||
total_sales: number;
|
||||
total_paid: number;
|
||||
total_due: number;
|
||||
count: number;
|
||||
by_day: { date: string; total: number }[];
|
||||
}
|
||||
|
||||
const IngresosReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [sales, setSales] = useState<SalesData | null>(null);
|
||||
const [byMethod, setByMethod] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [salesRes, cashRes] = await Promise.all([
|
||||
odooApi.getSalesReport(start, end),
|
||||
odooApi.getCashReportRange(start, end),
|
||||
]);
|
||||
if (salesRes.status === 'success') {
|
||||
setSales({
|
||||
total_sales: salesRes.total_sales,
|
||||
total_paid: salesRes.total_paid,
|
||||
total_due: salesRes.total_due,
|
||||
count: salesRes.count,
|
||||
by_day: salesRes.by_day || [],
|
||||
});
|
||||
}
|
||||
if (cashRes.status === 'success') setByMethod(cashRes.by_method);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar ingresos');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
if (!sales) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = sales.by_day.map((d) => ({
|
||||
fecha: d.date, total: d.total, metodo: '',
|
||||
}));
|
||||
Object.entries(byMethod).forEach(([k, v]) =>
|
||||
rows.push({ fecha: '', metodo: metodoLabels[k] || k, total: v }));
|
||||
await exportToExcel({
|
||||
filename: `ingresos-${start}_${end}`,
|
||||
sheetName: 'Ingresos',
|
||||
title: 'Ingresos',
|
||||
subtitle: `Del ${start} al ${end} · primeras filas por día, luego por método de cobro`,
|
||||
columns: [
|
||||
{ header: 'Fecha', key: 'fecha', format: 'date' },
|
||||
{ header: 'Método', key: 'metodo' },
|
||||
{ header: 'Total', key: 'total', format: 'currency' },
|
||||
],
|
||||
rows,
|
||||
totals: { fecha: '', metodo: 'Total vendido', total: sales.total_sales },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = (sales?.by_day || []).map((d) => ({
|
||||
name: new Date(`${d.date}T12:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }),
|
||||
total: d.total,
|
||||
}));
|
||||
const ticketPromedio = sales && sales.count > 0 ? sales.total_sales / sales.count : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : sales ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Total vendido</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(sales.total_sales)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Total cobrado</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(sales.total_paid)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Por cobrar</p>
|
||||
<p className={`text-xl font-heading font-semibold ${sales.total_due > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{formatCurrency(sales.total_due)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Ticket promedio</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(ticketPromedio)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Ingresos por día</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[280px] w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<BarChart3 size={28} />} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ingresosGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#57534e" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#57534e" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} tickFormatter={(v: number) => formatCurrency(v)} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}
|
||||
formatter={(v) => formatCurrency(Number(v))}
|
||||
/>
|
||||
<Area type="monotone" dataKey="total" stroke="#57534e" strokeWidth={2} fill="url(#ingresosGradient)" name="Ingresos" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Cobros por método</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : Object.keys(byMethod).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin cobros en el periodo.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(byMethod).map(([method, amount]) => (
|
||||
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
|
||||
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngresosReport;
|
||||
187
frontend/src/components/reportes/InventarioReport.tsx
Normal file
187
frontend/src/components/reportes/InventarioReport.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Boxes } from 'lucide-react';
|
||||
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type InventoryItem, type InventoryReportSummary } from '../../services/odoo';
|
||||
import type { BadgeVariant } from '../ui/Badge';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const levelBadge = (level: string): BadgeVariant => {
|
||||
switch (level) {
|
||||
case 'out': return 'danger';
|
||||
case 'critical': case 'low': return 'warning';
|
||||
default: return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const levelLabels: Record<string, string> = {
|
||||
out: 'Sin existencias', critical: 'Crítico', low: 'Bajo', optimal: 'Óptimo',
|
||||
};
|
||||
|
||||
const FILTROS = [
|
||||
{ key: 'todos', label: 'Todos' },
|
||||
{ key: 'bajo', label: 'Bajo mínimo' },
|
||||
{ key: 'out', label: 'Sin existencias' },
|
||||
] as const;
|
||||
|
||||
const InventarioReport: FC = () => {
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [summary, setSummary] = useState<InventoryReportSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtro, setFiltro] = useState<string>('todos');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getInventoryReport();
|
||||
if (res.status === 'success') {
|
||||
setItems(res.items);
|
||||
setSummary(res.summary);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar reporte de inventario');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const visibles = useMemo(() => {
|
||||
if (filtro === 'out') return items.filter((i) => i.stock_level === 'out');
|
||||
if (filtro === 'bajo') return items.filter((i) => i.stock_level === 'critical' || i.stock_level === 'low');
|
||||
return items;
|
||||
}, [items, filtro]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const filtroLabel = FILTROS.find((f) => f.key === filtro)?.label || 'Todos';
|
||||
await exportToExcel({
|
||||
filename: `inventario-${filtro}-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Inventario',
|
||||
title: 'Reporte de inventario',
|
||||
subtitle: `Filtro: ${filtroLabel} · ${visibles.length} artículos · valor total ${formatCurrency(summary?.total_value ?? 0)}`,
|
||||
columns: [
|
||||
{ header: 'Artículo', key: 'name' },
|
||||
{ header: 'Categoría', key: 'category' },
|
||||
{ header: 'Unidad', key: 'unit' },
|
||||
{ header: 'Existencia', key: 'qty', format: 'number' },
|
||||
{ header: 'Mínimo', key: 'qty_min', format: 'number' },
|
||||
{ header: 'Óptimo', key: 'qty_optimal', format: 'number' },
|
||||
{ header: 'Costo', key: 'cost', format: 'currency' },
|
||||
{ header: 'Valor', key: 'inventory_value', format: 'currency' },
|
||||
{ header: 'Nivel', key: 'nivel' },
|
||||
],
|
||||
rows: visibles.map((i) => ({ ...i, nivel: levelLabels[i.stock_level] || i.stock_level })),
|
||||
totals: { name: '', inventory_value: visibles.reduce((a, i) => a + i.inventory_value, 0) },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Valor del inventario</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(summary.total_value)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Artículos</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{summary.count}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Sin existencias</p>
|
||||
<p className={`text-xl font-heading font-semibold ${summary.out > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>{summary.out}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Bajo mínimo</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{summary.critical}</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||
{FILTROS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFiltro(f.key)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
|
||||
filtro === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-10 w-full" />
|
||||
) : visibles.length === 0 ? (
|
||||
<EmptyState title="Sin artículos" subtitle="No hay artículos con este filtro." icon={<Boxes size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Categoría</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Unidad</th>
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
|
||||
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Costo</th>
|
||||
<th className="text-right px-4 py-3">Valor</th>
|
||||
<th className="text-left px-4 py-3">Nivel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{visibles.map((i) => (
|
||||
<tr key={i.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{i.name}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{i.category || '-'}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{i.unit}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{i.qty}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{i.qty_min || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{i.qty_optimal || '-'}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(i.cost)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(i.inventory_value)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={levelBadge(i.stock_level)}>{levelLabels[i.stock_level] || i.stock_level}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventarioReport;
|
||||
244
frontend/src/components/reportes/KpisReport.tsx
Normal file
244
frontend/src/components/reportes/KpisReport.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Gauge, UserX, TrendingUp, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type KpisReport as KpisData } from '../../services/odoo';
|
||||
|
||||
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; rose?: boolean }> = ({ label, value, icon, rose }) => (
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-theme-muted">{icon}</span>
|
||||
<p className="text-xs text-theme-muted">{label}</p>
|
||||
</div>
|
||||
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const KpisReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [data, setData] = useState<KpisData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getKpis(start, end);
|
||||
if (res.status === 'success') {
|
||||
const { no_show, ocupacion, leads, primera_vez, start: s, end: e } = res;
|
||||
setData({ no_show, ocupacion, leads, primera_vez, start: s, end: e });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar KPIs');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
data.no_show.por_medico.forEach((m) =>
|
||||
rows.push({ seccion: 'No-show', medico: m.medico, citas: m.citas, casos: m.no_shows, horas: null, tasa: m.tasa }));
|
||||
data.ocupacion.por_medico.forEach((o) =>
|
||||
rows.push({ seccion: 'Ocupación', medico: o.medico, citas: null, casos: null, horas: o.horas_vendidas, tasa: o.ocupacion }));
|
||||
rows.push({ seccion: 'Resumen', medico: 'No-show global', citas: data.no_show.total_citas, casos: data.no_show.total_no_shows, horas: null, tasa: data.no_show.tasa });
|
||||
rows.push({ seccion: 'Resumen', medico: 'Conversión leads', citas: data.leads.total, casos: data.leads.ganados, horas: null, tasa: data.leads.tasa });
|
||||
rows.push({ seccion: 'Resumen', medico: 'Primera vez', citas: data.primera_vez.primera + data.primera_vez.subsecuentes, casos: data.primera_vez.primera, horas: null, tasa: data.primera_vez.tasa });
|
||||
await exportToExcel({
|
||||
filename: `kpis-${start}_${end}`,
|
||||
sheetName: 'KPIs',
|
||||
title: 'KPIs clínicos SKEEN',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Sección', key: 'seccion' },
|
||||
{ header: 'Médico / Concepto', key: 'medico' },
|
||||
{ header: 'Citas / Leads', key: 'citas', format: 'number' },
|
||||
{ header: 'Casos', key: 'casos', format: 'number' },
|
||||
{ header: 'Horas vendidas', key: 'horas', format: 'number' },
|
||||
{ header: 'Tasa', key: 'tasa', format: 'percent' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Kpi label="Tasa no-show" value={pct(data.no_show.tasa)} icon={<UserX size={15} />} rose={data.no_show.tasa > 0.15} />
|
||||
<Kpi label="Ocupación promedio" value={pct(data.ocupacion.promedio)} icon={<Gauge size={15} />} />
|
||||
<Kpi label="Conversión de leads" value={pct(data.leads.tasa)} icon={<TrendingUp size={15} />} />
|
||||
<Kpi label="Pacientes primera vez" value={pct(data.primera_vez.tasa)} icon={<UserPlus size={15} />} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* No-show por médico */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1">No-show por médico</h3>
|
||||
<p className="text-xs text-theme-muted mb-4">
|
||||
{data.no_show.total_no_shows} no-shows de {data.no_show.total_citas} citas (canceladas no cuentan).
|
||||
</p>
|
||||
{data.no_show.por_medico.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">Citas</th>
|
||||
<th className="text-right px-4 py-3">No-shows</th>
|
||||
<th className="text-right px-4 py-3">Tasa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.no_show.por_medico.map((m) => (
|
||||
<tr key={m.medico} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{m.medico}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{m.citas}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{m.no_shows}</td>
|
||||
<td className={`px-4 py-3 text-right font-medium ${m.tasa > 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{pct(m.tasa)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Ocupación por médico */}
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1">Ocupación por médico</h3>
|
||||
<p className="text-xs text-theme-muted mb-4">
|
||||
{data.ocupacion.dias_habiles} días hábiles × 8h (domingos no cuentan), menos bloqueos de agenda.
|
||||
</p>
|
||||
{data.ocupacion.por_medico.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Médico</th>
|
||||
<th className="text-right px-4 py-3">Vendidas</th>
|
||||
<th className="text-right px-4 py-3">Disponibles</th>
|
||||
<th className="text-right px-4 py-3">Libres</th>
|
||||
<th className="text-right px-4 py-3">Ocupación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.ocupacion.por_medico.map((o) => (
|
||||
<tr key={o.medico} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{o.medico}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_vendidas}h</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_disponibles}h</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_libres}h</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{pct(o.ocupacion)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{data.ocupacion.por_medico.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={data.ocupacion.por_medico.map((o) => ({ name: o.medico.split(' ')[0], ocupacion: Math.round(o.ocupacion * 100) }))} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} unit="%" />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} formatter={(v) => `${v}%`} />
|
||||
<Bar dataKey="ocupacion" fill="#57534e" radius={[4, 4, 0, 0]} name="Ocupación" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Leads y primera vez */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted mb-1">Conversión de leads (deals creados en el periodo)</p>
|
||||
<p className="text-sm text-theme-heading">
|
||||
<span className="font-semibold">{data.leads.ganados}</span> ganados de{' '}
|
||||
<span className="font-semibold">{data.leads.total}</span> leads ({pct(data.leads.tasa)})
|
||||
{data.leads.dias_promedio_ganar !== null && (
|
||||
<span className="text-theme-muted"> · prom. {data.leads.dias_promedio_ganar} días a ganar</span>
|
||||
)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted mb-1">Primera vez vs subsecuentes</p>
|
||||
<p className="text-sm text-theme-heading">
|
||||
<span className="font-semibold">{data.primera_vez.primera}</span> primera vez ·{' '}
|
||||
<span className="font-semibold">{data.primera_vez.subsecuentes}</span> subsecuentes
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="Sin datos" subtitle="No se pudieron calcular los KPIs." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KpisReport;
|
||||
118
frontend/src/components/reportes/PagosClientesReport.tsx
Normal file
118
frontend/src/components/reportes/PagosClientesReport.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type PagoClienteRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const PagosClientesReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [clientes, setClientes] = useState<PagoClienteRow[]>([]);
|
||||
const [totalCobrado, setTotalCobrado] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getPagosClientes(start, end);
|
||||
if (res.status === 'success') {
|
||||
setClientes(res.clientes);
|
||||
setTotalCobrado(res.total_cobrado);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar pagos por cliente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `pagos-clientes-${start}_${end}`,
|
||||
sheetName: 'Pagos por cliente',
|
||||
title: 'Pagos · Clientes',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Cliente', key: 'name' },
|
||||
{ header: '# Pagos', key: 'num_pagos', format: 'number' },
|
||||
{ header: 'Total cobrado', key: 'total_cobrado', format: 'currency' },
|
||||
{ header: 'Último pago', key: 'ultimo_pago' },
|
||||
],
|
||||
rows: clientes.map((c) => ({ ...c })),
|
||||
totals: { name: '', num_pagos: clientes.reduce((a, c) => a + c.num_pagos, 0), total_cobrado: totalCobrado },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Total cobrado: <span className="font-semibold text-theme-heading">{formatCurrency(totalCobrado)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : clientes.length === 0 ? (
|
||||
<EmptyState title="Sin cobros" subtitle="No hay pagos registrados en el periodo." icon={<Users size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Cliente</th>
|
||||
<th className="text-right px-4 py-3"># Pagos</th>
|
||||
<th className="text-right px-4 py-3">Total cobrado</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Último pago</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{clientes.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.num_pagos}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total_cobrado)}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.ultimo_pago || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PagosClientesReport;
|
||||
169
frontend/src/components/reportes/PagosServiciosReport.tsx
Normal file
169
frontend/src/components/reportes/PagosServiciosReport.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type PagoServicioRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const metodoLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const PagosServiciosReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [servicios, setServicios] = useState<PagoServicioRow[]>([]);
|
||||
const [byMethod, setByMethod] = useState<Record<string, number>>({});
|
||||
const [totalCobrado, setTotalCobrado] = useState(0);
|
||||
const [totalPagadoCompleto, setTotalPagadoCompleto] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getPagosServicios(start, end);
|
||||
if (res.status === 'success') {
|
||||
setServicios(res.servicios);
|
||||
setByMethod(res.by_method);
|
||||
setTotalCobrado(res.total_cobrado);
|
||||
setTotalPagadoCompleto(res.total_pagado_completo);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar pagos por servicio');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = servicios.map((s) => ({
|
||||
servicio: s.servicio, cantidad: s.cantidad, total: s.total, metodo: '', monto: null,
|
||||
}));
|
||||
Object.entries(byMethod).forEach(([k, v]) =>
|
||||
rows.push({ servicio: '', cantidad: null, total: null, metodo: metodoLabels[k] || k, monto: v }));
|
||||
await exportToExcel({
|
||||
filename: `pagos-servicios-${start}_${end}`,
|
||||
sheetName: 'Pagos por servicio',
|
||||
title: 'Pagos · Servicios',
|
||||
subtitle: `Del ${start} al ${end} · ventas liquidadas por servicio y cobros por método`,
|
||||
columns: [
|
||||
{ header: 'Servicio', key: 'servicio' },
|
||||
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
|
||||
{ header: 'Total', key: 'total', format: 'currency' },
|
||||
{ header: 'Método', key: 'metodo' },
|
||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||
],
|
||||
rows,
|
||||
totals: { servicio: '', total: totalPagadoCompleto, monto: totalCobrado },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Total cobrado (pagos del periodo)</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(totalCobrado)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Ventas liquidadas en el periodo</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(totalPagadoCompleto)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-1">Cobrado por servicio</h3>
|
||||
<p className="text-xs text-theme-muted mb-4">Ventas totalmente pagadas del periodo, agrupadas por servicio.</p>
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-10 w-full" />
|
||||
) : servicios.length === 0 ? (
|
||||
<EmptyState title="Sin datos" subtitle="No hay ventas liquidadas en el periodo." icon={<CreditCard size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Servicio</th>
|
||||
<th className="text-right px-4 py-3">Cantidad</th>
|
||||
<th className="text-right px-4 py-3">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{servicios.map((s, i) => (
|
||||
<tr key={i} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{s.servicio}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{s.cantidad}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(s.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-xl text-theme-heading mb-4">Cobros por método</h3>
|
||||
{loading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : Object.keys(byMethod).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin cobros en el periodo.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(byMethod).map(([method, amount]) => (
|
||||
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
|
||||
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PagosServiciosReport;
|
||||
106
frontend/src/components/reportes/PaquetesReport.tsx
Normal file
106
frontend/src/components/reportes/PaquetesReport.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type PaqueteRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const PaquetesReport: FC = () => {
|
||||
const [paquetes, setPaquetes] = useState<PaqueteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getPaquetes();
|
||||
if (res.status === 'success') setPaquetes(res.paquetes);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar paquetes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `paquetes-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Paquetes',
|
||||
title: 'Paquetes de sesiones',
|
||||
columns: [
|
||||
{ header: 'Servicio', key: 'name' },
|
||||
{ header: 'Sesiones', key: 'package_sessions', format: 'number' },
|
||||
{ header: 'Precio paquete', key: 'package_price', format: 'currency' },
|
||||
{ header: 'Precio sesión', key: 'price', format: 'currency' },
|
||||
{ header: 'Citas agendadas', key: 'citas', format: 'number' },
|
||||
{ header: 'Paquetes terminados', key: 'terminados', format: 'number' },
|
||||
],
|
||||
rows: paquetes.map((p) => ({ ...p })),
|
||||
totals: { name: '', citas: paquetes.reduce((a, p) => a + p.citas, 0), terminados: paquetes.reduce((a, p) => a + p.terminados, 0) },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<h3 className="font-heading text-xl text-theme-heading">Paquetes de sesiones</h3>
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mb-4">Servicios vendidos como paquete: sesiones contratadas, citas agendadas y paquetes terminados.</p>
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : paquetes.length === 0 ? (
|
||||
<EmptyState title="Sin paquetes" subtitle="No hay servicios configurados como paquete." icon={<Package size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Servicio</th>
|
||||
<th className="text-right px-4 py-3">Sesiones</th>
|
||||
<th className="text-right px-4 py-3">Precio paquete</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Precio sesión</th>
|
||||
<th className="text-right px-4 py-3">Citas agendadas</th>
|
||||
<th className="text-right px-4 py-3">Paquetes terminados</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{paquetes.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{p.name}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{p.package_sessions}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(p.package_price)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(p.price)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{p.citas}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Badge variant={p.terminados > 0 ? 'success' : 'default'}>{p.terminados}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaquetesReport;
|
||||
104
frontend/src/components/reportes/RecomendacionesReport.tsx
Normal file
104
frontend/src/components/reportes/RecomendacionesReport.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { Card, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type RecomendacionRow } from '../../services/odoo';
|
||||
|
||||
const RecomendacionesReport: FC = () => {
|
||||
const [recomendaciones, setRecomendaciones] = useState<RecomendacionRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getRecomendaciones();
|
||||
if (res.status === 'success') {
|
||||
setRecomendaciones(res.recomendaciones);
|
||||
setTotal(res.total);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar recomendaciones');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `recomendaciones-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Recomendaciones',
|
||||
title: 'Pacientes por recomendación',
|
||||
subtitle: `${total} pacientes recomendados`,
|
||||
columns: [
|
||||
{ header: '#', key: 'rank', format: 'number' },
|
||||
{ header: 'Recomendador', key: 'recomendador' },
|
||||
{ header: 'Pacientes', key: 'pacientes', format: 'number' },
|
||||
],
|
||||
rows: recomendaciones.map((r, i) => ({ ...r, rank: i + 1 })),
|
||||
totals: { recomendador: '', pacientes: total },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-heading text-xl text-theme-heading">Pacientes por recomendación</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-theme-muted">{total} pacientes recomendados</p>
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : recomendaciones.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin recomendaciones"
|
||||
subtitle="Ningún paciente tiene capturado el campo “Recomendado por”."
|
||||
icon={<Share2 size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">#</th>
|
||||
<th className="text-left px-4 py-3">Recomendador</th>
|
||||
<th className="text-right px-4 py-3">Pacientes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{recomendaciones.map((r, i) => (
|
||||
<tr key={r.recomendador} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 text-theme-muted">{i + 1}</td>
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{r.recomendador}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.pacientes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecomendacionesReport;
|
||||
113
frontend/src/components/reportes/TopClientesReport.tsx
Normal file
113
frontend/src/components/reportes/TopClientesReport.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type TopClienteRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const TopClientesReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [clientes, setClientes] = useState<TopClienteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getTopClientes(start, end, 20);
|
||||
if (res.status === 'success') setClientes(res.clientes);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar top clientes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `top-clientes-${start}_${end}`,
|
||||
sheetName: 'Top clientes',
|
||||
title: 'Top clientes por compras',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: '#', key: 'rank', format: 'number' },
|
||||
{ header: 'Cliente', key: 'name' },
|
||||
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
|
||||
{ header: 'Total comprado', key: 'total', format: 'currency' },
|
||||
{ header: 'Ticket promedio', key: 'ticket_promedio', format: 'currency' },
|
||||
],
|
||||
rows: clientes.map((c, i) => ({ ...c, rank: i + 1 })),
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : clientes.length === 0 ? (
|
||||
<EmptyState title="Sin ventas" subtitle="No hay ventas en el periodo." icon={<Trophy size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">#</th>
|
||||
<th className="text-left px-4 py-3">Cliente</th>
|
||||
<th className="text-right px-4 py-3"># Ventas</th>
|
||||
<th className="text-right px-4 py-3">Total comprado</th>
|
||||
<th className="text-right px-4 py-3">Ticket promedio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{clientes.map((c, i) => (
|
||||
<tr key={c.id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 text-theme-muted">{i + 1}</td>
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{c.num_ventas}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(c.ticket_promedio)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopClientesReport;
|
||||
117
frontend/src/components/reportes/VendedoresReport.tsx
Normal file
117
frontend/src/components/reportes/VendedoresReport.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { UserCheck } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type VendedorRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const VendedoresReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [vendedores, setVendedores] = useState<VendedorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getVendedores(start, end);
|
||||
if (res.status === 'success') setVendedores(res.vendedores);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar vendedores');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `vendedores-${start}_${end}`,
|
||||
sheetName: 'Vendedores',
|
||||
title: 'Ventas por vendedor',
|
||||
subtitle: `Del ${start} al ${end} · por usuario que registró la venta`,
|
||||
columns: [
|
||||
{ header: 'Usuario', key: 'usuario' },
|
||||
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
|
||||
{ header: 'Total vendido', key: 'total', format: 'currency' },
|
||||
{ header: 'Cobrado', key: 'cobrado', format: 'currency' },
|
||||
],
|
||||
rows: vendedores.map((v) => ({ ...v })),
|
||||
totals: {
|
||||
usuario: '',
|
||||
num_ventas: vendedores.reduce((a, v) => a + v.num_ventas, 0),
|
||||
total: vendedores.reduce((a, v) => a + v.total, 0),
|
||||
cobrado: vendedores.reduce((a, v) => a + v.cobrado, 0),
|
||||
},
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<div className="sm:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mb-4">Ventas agrupadas por el usuario que las registró en el sistema.</p>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : vendedores.length === 0 ? (
|
||||
<EmptyState title="Sin ventas" subtitle="No hay ventas registradas en el periodo." icon={<UserCheck size={28} />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Usuario</th>
|
||||
<th className="text-right px-4 py-3"># Ventas</th>
|
||||
<th className="text-right px-4 py-3">Total vendido</th>
|
||||
<th className="text-right px-4 py-3">Cobrado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{vendedores.map((v, i) => (
|
||||
<tr key={i} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3 font-medium text-theme-heading">{v.usuario}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{v.num_ventas}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(v.total)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(v.cobrado)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendedoresReport;
|
||||
18
frontend/src/components/ui/ExportButton.tsx
Normal file
18
frontend/src/components/ui/ExportButton.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FC } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ExportButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Botón estándar de exportación de reportes (xlsx), arriba a la derecha */
|
||||
export const ExportButton: FC<ExportButtonProps> = ({ onClick, loading }) => (
|
||||
<Button variant="outline" size="sm" onClick={onClick} loading={loading}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
Exportar
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default ExportButton;
|
||||
@@ -13,6 +13,10 @@ interface ModalProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
// Contador global de modales abiertos: el scroll del body solo se restaura
|
||||
// cuando se cierra el ÚLTIMO modal (soporta modales anidados).
|
||||
let openModals = 0;
|
||||
|
||||
const maxWidthClasses: Record<NonNullable<ModalProps['maxWidth']>, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
@@ -31,20 +35,23 @@ export const Modal: FC<ModalProps> = ({
|
||||
footer,
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
openModals += 1;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
openModals = Math.max(0, openModals - 1);
|
||||
if (openModals === 0) document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user