From a916753ebe3d77a816c031f830b3a9d1916afded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 12:17:23 +0000 Subject: [PATCH] fase 3.2: compras y proveedores (modulo skeen_compras: proveedores, ordenes de compra con recepcion total/parcial, costo promedio ponderado con lote y caducidad, pagos a proveedor, cuentas por pagar con antiguedad, reportes de compras y margen por articulo); apagado por defecto --- frontend/src/App.tsx | 14 +- frontend/src/components/ClasicoLayout.tsx | 22 +- frontend/src/components/ComprasPanel.tsx | 149 +++ frontend/src/components/HomeNestLayout.tsx | 22 +- frontend/src/components/SkeenLayout.tsx | 23 +- .../components/compras/CompraDetalleModal.tsx | 179 ++++ .../components/compras/CompraFormModal.tsx | 240 +++++ .../compras/CuentasPorPagarPanel.tsx | 148 +++ .../components/compras/PagoCompraModal.tsx | 88 ++ .../components/compras/ProveedoresPanel.tsx | 257 +++++ .../src/components/compras/RecepcionModal.tsx | 191 ++++ frontend/src/components/compras/shared.ts | 29 + .../src/components/reportes/ComprasReport.tsx | 185 ++++ .../src/components/reportes/MargenReport.tsx | 144 +++ frontend/src/lib/auth.tsx | 5 + frontend/src/lib/clinic-settings.ts | 3 +- frontend/src/lib/menu-items.ts | 1 + frontend/src/lib/permissions.ts | 9 + frontend/src/pages/Compras.tsx | 359 +++++++ frontend/src/pages/Configuracion.tsx | 11 +- frontend/src/pages/Inventario.tsx | 48 + frontend/src/pages/Reportes.tsx | 13 +- frontend/src/services/odoo.ts | 329 ++++++ odoo-addons/skeen_citas/clinic_settings.py | 92 ++ odoo-addons/skeen_compras/__init__.py | 4 + odoo-addons/skeen_compras/__manifest__.py | 36 + .../skeen_compras/controllers/__init__.py | 2 + .../skeen_compras/controllers/compras.py | 950 ++++++++++++++++++ odoo-addons/skeen_compras/data/sequences.xml | 14 + odoo-addons/skeen_compras/hooks.py | 19 + odoo-addons/skeen_compras/models/__init__.py | 4 + odoo-addons/skeen_compras/models/compra.py | 424 ++++++++ .../skeen_compras/models/inventario.py | 92 ++ odoo-addons/skeen_compras/models/partner.py | 69 ++ .../security/ir.model.access.csv | 6 + .../skeen_whatsapp/controllers/frontend.py | 22 +- odoo-addons/skeen_whatsapp/permissions.py | 23 + odoo-addons/skeen_whatsapp/roles.py | 5 + 38 files changed, 4201 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/ComprasPanel.tsx create mode 100644 frontend/src/components/compras/CompraDetalleModal.tsx create mode 100644 frontend/src/components/compras/CompraFormModal.tsx create mode 100644 frontend/src/components/compras/CuentasPorPagarPanel.tsx create mode 100644 frontend/src/components/compras/PagoCompraModal.tsx create mode 100644 frontend/src/components/compras/ProveedoresPanel.tsx create mode 100644 frontend/src/components/compras/RecepcionModal.tsx create mode 100644 frontend/src/components/compras/shared.ts create mode 100644 frontend/src/components/reportes/ComprasReport.tsx create mode 100644 frontend/src/components/reportes/MargenReport.tsx create mode 100644 frontend/src/pages/Compras.tsx create mode 100644 odoo-addons/skeen_compras/__init__.py create mode 100644 odoo-addons/skeen_compras/__manifest__.py create mode 100644 odoo-addons/skeen_compras/controllers/__init__.py create mode 100644 odoo-addons/skeen_compras/controllers/compras.py create mode 100644 odoo-addons/skeen_compras/data/sequences.xml create mode 100644 odoo-addons/skeen_compras/hooks.py create mode 100644 odoo-addons/skeen_compras/models/__init__.py create mode 100644 odoo-addons/skeen_compras/models/compra.py create mode 100644 odoo-addons/skeen_compras/models/inventario.py create mode 100644 odoo-addons/skeen_compras/models/partner.py create mode 100644 odoo-addons/skeen_compras/security/ir.model.access.csv diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b44feb..10b7772 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import Reportes from './pages/Reportes'; import Configuracion from './pages/Configuracion'; import Cumpleanos from './pages/Cumpleanos'; import Inventario from './pages/Inventario'; +import Compras from './pages/Compras'; import WacrmMessages from './pages/WacrmMessages'; import WacrmLeads from './pages/WacrmLeads'; import Chat from './pages/Chat'; @@ -108,7 +109,7 @@ const LoginRoute: FC = () => { const LANDING_CANDIDATOS = [ '/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas', '/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero', - '/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages', + '/inventario', '/compras', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages', '/wacrm/leads', '/configuracion', ]; @@ -121,6 +122,15 @@ const MembresiasRoute: FC = () => { return ; }; +// Compras vive detrás de `compras.activo`. Apagado, la ruta rebota a "/" aunque +// el rol tenga permiso de menú (así apagarlo vuelve todo al estado anterior, +// incluso entrando por URL). +const ComprasRoute: FC = () => { + const { compras } = useClinicSettings(); + if (!compras.activo) return ; + return ; +}; + const HomeRoute: FC = () => { const { user, canSee } = useAuth(); if (user?.role === 'medico') return ; @@ -181,6 +191,8 @@ const App: FC = () => { } /> } /> } /> + {/* Compras solo cuando la clínica la activa (compras.activo). */} + } /> } /> } /> } /> diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index 72743d9..435e58e 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -146,17 +146,23 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); - const { membresias: membresiasCfg } = useClinicSettings(); + const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings(); const [sidebarOpen, setSidebarOpen] = useState(false); const chatUnread = useChatUnread(); - // Membresías solo cuando la clínica enciende `membresias.activo`. - const menuTreeT = useMemo( - () => (membresiasCfg.activo - ? [...menuTree, { label: 'Membresías', icon: Repeat, href: '/membresias' }] - : menuTree), - [membresiasCfg.activo] - ); + // Membresías y Compras solo cuando la clínica enciende su bandera. + const menuTreeT = useMemo(() => { + let tree = menuTree; + if (comprasCfg.activo) { + tree = tree.map((e) => (e.label === 'Consumibles' && e.children + ? { ...e, children: [...e.children, { label: 'Órdenes de compra', href: '/compras' }] } + : e)); + } + if (membresiasCfg.activo) { + tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }]; + } + return tree; + }, [membresiasCfg.activo, comprasCfg.activo]); const menuLeavesT = useMemo( () => menuTreeT.flatMap((e) => e.children diff --git a/frontend/src/components/ComprasPanel.tsx b/frontend/src/components/ComprasPanel.tsx new file mode 100644 index 0000000..d18f269 --- /dev/null +++ b/frontend/src/components/ComprasPanel.tsx @@ -0,0 +1,149 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Save, Truck } from 'lucide-react'; +import { Card, Button, Input, toast } from './ui'; +import { odooApi, COMPRAS_DEFAULT, type ClinicSettings, type ComprasSettings } from '../services/odoo'; +import ProveedoresPanel from './compras/ProveedoresPanel'; + +interface Props { + settings: ClinicSettings; + onSaved: (settings: ClinicSettings) => void; +} + +const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => ( + +); + +// Configuración → Compras: ajustes del bloque `compras` + acceso a proveedores. +// Mientras `activo` esté apagado no aparece la sección en el menú y el +// inventario y el POS se ven idénticos; este panel es la llave para encenderlo. +const ComprasPanel: FC = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.compras || COMPRAS_DEFAULT); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setForm(settings.compras || COMPRAS_DEFAULT); + }, [settings.compras]); + + const set = (k: K, v: ComprasSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const guardar = async () => { + setSaving(true); + try { + const res = await odooApi.putClinicSettings({ + compras: { + ...form, + dias_credito_default: Number(form.dias_credito_default) || 0, + margen_minimo_alerta: Number(form.margen_minimo_alerta) || 0, + }, + motivo: 'Configuración de compras', + }); + if (res.status === 'success') { + onSaved(res.settings); + toast.success('Configuración de compras guardada'); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo guardar la configuración'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

+ Compras y proveedores +

+

+ Con el módulo apagado no aparece la sección Compras, el inventario y el POS se ven + igual que siempre y no se recalcula ningún costo. +

+
+ +
+ +
+ set('activo', v)} + ayuda="Enciende la sección Compras, las recepciones, el costo promedio y los reportes." + /> + set('actualizar_costo_promedio', v)} + ayuda="Recalcula el costo promedio ponderado del artículo en cada recepción." + /> + set('permitir_recepcion_parcial', v)} + ayuda="Permite recibir menos de lo pedido y completar después." + /> + set('requiere_autorizacion', v)} + ayuda="Las órdenes nacen en borrador y deben enviarse antes de poder recibirse." + /> + set('permitir_compras_sin_orden', v)} + ayuda="Reservado para registrar recepciones directas sin orden de compra." + /> +
+ set('dias_credito_default', Number(e.target.value) as unknown as number)} + /> + set('margen_minimo_alerta', Number(e.target.value) as unknown as number)} + /> +
+
+ + + +

Proveedores

+
+ + {form.activo ? ( + + ) : ( +

+ Activa Compras y guarda para administrar proveedores desde aquí. También puedes + hacerlo desde Compras › Proveedores. +

+ )} +
+
+
+ ); +}; + +export default ComprasPanel; diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index 03f0340..44dfd24 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -37,6 +37,7 @@ import { Layers, Download, Repeat, + Truck, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -113,25 +114,32 @@ const MENU_SECTIONS = [ ]; const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' }; +const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' }; export const HomeNestLayout: FC = ({ children, title: _title, subtitle: _subtitle }) => { const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); const { term } = useGlosario(); - const { membresias: membresiasCfg } = useClinicSettings(); + const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings(); const topNavItems = useMemo( () => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })), [term] ); const menuSections = useMemo(() => { - const base = membresiasCfg.activo - ? MENU_SECTIONS.map((s) => (s.label === 'Clínica' - ? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] } - : s)) - : MENU_SECTIONS; + let base = MENU_SECTIONS; + if (membresiasCfg.activo) { + base = base.map((s) => (s.label === 'Clínica' + ? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] } + : s)); + } + if (comprasCfg.activo) { + base = base.map((s) => (s.label === 'Operaciones' + ? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] } + : s)); + } return traducirMenu(base, term); - }, [term, membresiasCfg.activo]); + }, [term, membresiasCfg.activo, comprasCfg.activo]); const [menuOpen, setMenuOpen] = useState(false); const [expanded, setExpanded] = useState(['Clínica']); const chatUnread = useChatUnread(); diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index 99ae45e..3275d98 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -39,6 +39,7 @@ import { Clock, Download, Repeat, + Truck, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -124,21 +125,29 @@ const menuGroups = [ // `membresias.activo`. Con el módulo apagado (default) el menú queda idéntico // al de siempre. const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' }; +// La sección Compras solo existe con `compras.activo=true`. +const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' }; export const SkeenLayout: FC = ({ children, title, subtitle }) => { const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); const { term } = useGlosario(); - const { membresias: membresiasCfg } = useClinicSettings(); + const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings(); const menuGroupsT = useMemo(() => { - const base = membresiasCfg.activo - ? menuGroups.map((g) => (g.label === 'Clínica' - ? { ...g, items: [...g.items, MEMBRESIA_ITEM] } - : g)) - : menuGroups; + let base = menuGroups; + if (membresiasCfg.activo) { + base = base.map((g) => (g.label === 'Clínica' + ? { ...g, items: [...g.items, MEMBRESIA_ITEM] } + : g)); + } + if (comprasCfg.activo) { + base = base.map((g) => (g.label === 'Operaciones' + ? { ...g, items: [...g.items, COMPRAS_ITEM] } + : g)); + } return traducirMenu(base, term); - }, [term, membresiasCfg.activo]); + }, [term, membresiasCfg.activo, comprasCfg.activo]); const [sidebarOpen, setSidebarOpen] = useState(false); const chatUnread = useChatUnread(); // Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas diff --git a/frontend/src/components/compras/CompraDetalleModal.tsx b/frontend/src/components/compras/CompraDetalleModal.tsx new file mode 100644 index 0000000..0ebb4f7 --- /dev/null +++ b/frontend/src/components/compras/CompraDetalleModal.tsx @@ -0,0 +1,179 @@ +import type { FC } from 'react'; +import { Send, PackageCheck, CreditCard, Ban, Pencil } from 'lucide-react'; +import { Modal, Button, Badge } from '../ui'; +import type { Compra } from '../../services/odoo'; +import { ESTADO_META, fmtMoney, fmtNum } from './shared'; + +interface Props { + isOpen: boolean; + onClose: () => void; + compra: Compra | null; + puedeEditar: boolean; + puedeRecibir: boolean; + puedePagar: boolean; + puedeCancelar: boolean; + puedeCrear: boolean; + onEdit: (c: Compra) => void; + onEnviar: (c: Compra) => void; + onRecibir: (c: Compra) => void; + onPagar: (c: Compra) => void; + onCancelar: (c: Compra) => void; +} + +const CompraDetalleModal: FC = ({ + isOpen, onClose, compra, puedeEditar, puedeRecibir, puedePagar, puedeCancelar, puedeCrear, + onEdit, onEnviar, onRecibir, onPagar, onCancelar, +}) => { + if (!compra) return null; + const meta = ESTADO_META[compra.estado]; + return ( + + {puedeEditar && ['borrador', 'enviada'].includes(compra.estado) && ( + + )} + {puedeCrear && compra.estado === 'borrador' && ( + + )} + {puedeRecibir && compra.puede_recibir && ( + + )} + {puedePagar && compra.estado !== 'cancelada' && compra.saldo > 0 && ( + + )} + {puedeCancelar && compra.puede_cancelar && ( + + )} + + )} + > +
+
+ {meta.label} + {compra.proveedor} + Fecha {compra.fecha} + {compra.fecha_entrega_esperada && Entrega esperada {compra.fecha_entrega_esperada}} + Moneda {compra.moneda} + {compra.recibida_at && Recibida {compra.recibida_at}} +
+ + {compra.cancel_reason && ( +

Cancelada: {compra.cancel_reason}

+ )} + +
+
+

Subtotal

+

{fmtMoney(compra.subtotal, compra.moneda)}

+
+
+

Impuestos

+

{fmtMoney(compra.impuestos, compra.moneda)}

+
+
+

Total

+

{fmtMoney(compra.total, compra.moneda)}

+
+
+

Pagado / Saldo

+

+ {fmtMoney(compra.pagado, compra.moneda)} / {fmtMoney(compra.saldo, compra.moneda)} +

+
+
+ +
+

Líneas

+
+ + + + + + + + + + + + + + {(compra.lineas || []).map((l) => ( + + + + + + + + + + ))} + +
ArtículoPedidaRecibidaPendienteCostoSubtotalLote / Cad.
{l.item}{fmtNum(l.cantidad_pedida)}{fmtNum(l.cantidad_recibida)}{fmtNum(l.cantidad_pendiente)}{fmtMoney(l.costo_unitario, compra.moneda)}{fmtMoney(l.subtotal, compra.moneda)} + {l.lote || '—'}{l.fecha_caducidad ? ` · ${l.fecha_caducidad}` : ''} +
+
+
+ +
+
+

Recepciones

+ {(compra.recepciones || []).length === 0 ? ( +

Sin recepciones todavía.

+ ) : ( +
    + {(compra.recepciones || []).map((r) => ( +
  • +
    + {r.fecha} + {fmtNum(r.total_unidades)} u · {fmtMoney(r.total_costo, compra.moneda)} +
    +

    {r.usuario || '—'}{r.notas ? ` · ${r.notas}` : ''}

    +
      + {r.lineas.map((ln) => ( +
    • + {ln.item}: {fmtNum(ln.cantidad)} u{ln.lote ? ` · lote ${ln.lote}` : ''}{ln.fecha_caducidad ? ` · cad. ${ln.fecha_caducidad}` : ''} +
    • + ))} +
    +
  • + ))} +
+ )} +
+
+

Pagos

+ {(compra.pagos || []).length === 0 ? ( +

Sin pagos registrados.

+ ) : ( +
    + {(compra.pagos || []).map((p) => ( +
  • +
    +

    {fmtMoney(p.monto, p.moneda)}

    +

    {p.fecha} · {p.metodo_label}{p.referencia ? ` · ${p.referencia}` : ''}

    +
    + {p.moneda !== compra.moneda && ( + = {fmtMoney(p.monto_en_compra, compra.moneda)} + )} +
  • + ))} +
+ )} +
+
+ + {compra.notas &&

Observaciones: {compra.notas}

} + Los pagos a proveedores son egresos y no se mezclan con los cobros a pacientes. +
+
+ ); +}; + +export default CompraDetalleModal; diff --git a/frontend/src/components/compras/CompraFormModal.tsx b/frontend/src/components/compras/CompraFormModal.tsx new file mode 100644 index 0000000..c3e7f1d --- /dev/null +++ b/frontend/src/components/compras/CompraFormModal.tsx @@ -0,0 +1,240 @@ +import type { FC } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { Modal, Button, Input, TextArea, Select, toast } from '../ui'; +import { odooApi, type Compra, type InventoryItem, type MonedaCompra, type Proveedor } from '../../services/odoo'; +import { MONEDA_OPTIONS, fmtMoney } from './shared'; + +interface LineaForm { + item_id: string; + cantidad_pedida: string; + costo_unitario: string; + descuento: string; + lote: string; + fecha_caducidad: string; +} + +const emptyLinea: LineaForm = { + item_id: '', cantidad_pedida: '1', costo_unitario: '0', descuento: '0', + lote: '', fecha_caducidad: '', +}; + +interface Props { + isOpen: boolean; + onClose: () => void; + compra: Compra | null; + onDone: (compraId?: number) => void; +} + +const hoy = () => new Date().toISOString().slice(0, 10); + +const CompraFormModal: FC = ({ isOpen, onClose, compra, onDone }) => { + const [proveedores, setProveedores] = useState([]); + const [items, setItems] = useState([]); + const [partnerId, setPartnerId] = useState(''); + const [fecha, setFecha] = useState(hoy()); + const [entrega, setEntrega] = useState(''); + const [moneda, setMoneda] = useState('MXN'); + const [descuento, setDescuento] = useState('0'); + const [referencia, setReferencia] = useState(''); + const [notas, setNotas] = useState(''); + const [lineas, setLineas] = useState([{ ...emptyLinea }]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + odooApi.getProveedores({ page_size: 500 }) + .then((res) => { if (res.status === 'success') setProveedores(res.proveedores || []); }) + .catch(() => {}); + odooApi.getInventory() + .then((res) => setItems(res.items || [])) + .catch(() => {}); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + if (compra) { + setPartnerId(String(compra.partner_id)); + setFecha(compra.fecha || hoy()); + setEntrega(compra.fecha_entrega_esperada || ''); + setMoneda(compra.moneda); + setDescuento(String(compra.descuento || 0)); + setReferencia(compra.referencia_factura || ''); + setNotas(compra.notas || ''); + setLineas((compra.lineas || []).map((l) => ({ + item_id: String(l.item_id), + cantidad_pedida: String(l.cantidad_pedida), + costo_unitario: String(l.costo_unitario), + descuento: String(l.descuento || 0), + lote: l.lote || '', + fecha_caducidad: l.fecha_caducidad || '', + }))); + } else { + setPartnerId(''); setFecha(hoy()); setEntrega(''); setMoneda('MXN'); + setDescuento('0'); setReferencia(''); setNotas(''); + setLineas([{ ...emptyLinea }]); + } + }, [isOpen, compra]); + + const itemMap = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]); + + const setLinea = (idx: number, patch: Partial) => + setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l))); + + const onPickItem = (idx: number, itemId: string) => { + const it = itemMap.get(Number(itemId)); + const costo = it ? (it.ultimo_costo || it.costo_promedio || it.cost || 0) : 0; + setLinea(idx, { item_id: itemId, costo_unitario: String(costo) }); + }; + + const subtotalLinea = (l: LineaForm) => + Math.max(0, (parseFloat(l.cantidad_pedida) || 0) * (parseFloat(l.costo_unitario) || 0) - (parseFloat(l.descuento) || 0)); + const subtotal = lineas.reduce((s, l) => s + subtotalLinea(l), 0); + const desc = parseFloat(descuento) || 0; + + const guardar = async () => { + if (!partnerId) { toast.error('Selecciona un proveedor'); return; } + const payload = lineas + .filter((l) => l.item_id && (parseFloat(l.cantidad_pedida) || 0) > 0) + .map((l) => ({ + item_id: Number(l.item_id), + cantidad_pedida: parseFloat(l.cantidad_pedida) || 0, + costo_unitario: parseFloat(l.costo_unitario) || 0, + descuento: parseFloat(l.descuento) || 0, + lote: l.lote, + fecha_caducidad: l.fecha_caducidad || null, + })); + if (payload.length === 0) { toast.error('Agrega al menos una línea con artículo y cantidad'); return; } + setSaving(true); + try { + const body = { + partner_id: Number(partnerId), + fecha, + fecha_entrega_esperada: entrega || null, + moneda, + descuento: desc, + notas, + referencia_factura: referencia, + lineas: payload, + }; + if (compra) { + await odooApi.updateCompra(compra.id, body); + } else { + await odooApi.crearOrdenCompra(body); + } + toast.success(compra ? 'Compra actualizada' : 'Compra creada'); + onDone(compra?.id); + onClose(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo guardar la compra'); + } finally { + setSaving(false); + } + }; + + return ( + + + Subtotal {fmtMoney(subtotal, moneda)} + {desc > 0 && <> · Descuento {fmtMoney(desc, moneda)}} + +
+ + +
+ + )} + > +
+
+ setFecha(e.target.value)} /> + setEntrega(e.target.value)} /> + setDescuento(e.target.value)} /> + setReferencia(e.target.value)} /> +
+ +
+ + + + + + + + + + + + + + + {lineas.map((l, idx) => ( + + + + + + + + + + + ))} + +
ArtículoCantidadCosto unit.Desc.LoteCaducidadSubtotal
+ + setLinea(idx, { cantidad_pedida: e.target.value })} /> + + setLinea(idx, { costo_unitario: e.target.value })} /> + + setLinea(idx, { descuento: e.target.value })} /> + + setLinea(idx, { lote: e.target.value })} /> + + setLinea(idx, { fecha_caducidad: e.target.value })} /> + {fmtMoney(subtotalLinea(l), moneda)} + +
+
+ + + +