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
This commit is contained in:
@@ -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 <Membresias />;
|
||||
};
|
||||
|
||||
// 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 <Navigate to="/" replace />;
|
||||
return <Compras />;
|
||||
};
|
||||
|
||||
const HomeRoute: FC = () => {
|
||||
const { user, canSee } = useAuth();
|
||||
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
|
||||
@@ -181,6 +191,8 @@ const App: FC = () => {
|
||||
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
|
||||
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
|
||||
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
||||
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
||||
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
||||
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
|
||||
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
|
||||
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
|
||||
|
||||
@@ -146,17 +146,23 @@ export const ClasicoLayout: FC<LayoutProps> = ({ 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<MenuEntry[]>(
|
||||
() => (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<MenuEntry[]>(() => {
|
||||
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
|
||||
|
||||
149
frontend/src/components/ComprasPanel.tsx
Normal file
149
frontend/src/components/ComprasPanel.tsx
Normal file
@@ -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 }) => (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
// 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<Props> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState<ComprasSettings>(settings.compras || COMPRAS_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(settings.compras || COMPRAS_DEFAULT);
|
||||
}, [settings.compras]);
|
||||
|
||||
const set = <K extends keyof ComprasSettings>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<Truck size={18} /> Compras y proveedores
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Toggle
|
||||
label="Activar compras"
|
||||
checked={form.activo}
|
||||
onChange={(v) => set('activo', v)}
|
||||
ayuda="Enciende la sección Compras, las recepciones, el costo promedio y los reportes."
|
||||
/>
|
||||
<Toggle
|
||||
label="Actualizar costo promedio al recibir"
|
||||
checked={form.actualizar_costo_promedio}
|
||||
onChange={(v) => set('actualizar_costo_promedio', v)}
|
||||
ayuda="Recalcula el costo promedio ponderado del artículo en cada recepción."
|
||||
/>
|
||||
<Toggle
|
||||
label="Permitir recepción parcial"
|
||||
checked={form.permitir_recepcion_parcial}
|
||||
onChange={(v) => set('permitir_recepcion_parcial', v)}
|
||||
ayuda="Permite recibir menos de lo pedido y completar después."
|
||||
/>
|
||||
<Toggle
|
||||
label="Requiere autorización (enviar antes de recibir)"
|
||||
checked={form.requiere_autorizacion}
|
||||
onChange={(v) => set('requiere_autorizacion', v)}
|
||||
ayuda="Las órdenes nacen en borrador y deben enviarse antes de poder recibirse."
|
||||
/>
|
||||
<Toggle
|
||||
label="Permitir compras sin orden"
|
||||
checked={form.permitir_compras_sin_orden}
|
||||
onChange={(v) => set('permitir_compras_sin_orden', v)}
|
||||
ayuda="Reservado para registrar recepciones directas sin orden de compra."
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Días de crédito por defecto"
|
||||
type="number"
|
||||
value={String(form.dias_credito_default)}
|
||||
onChange={(e) => set('dias_credito_default', Number(e.target.value) as unknown as number)}
|
||||
/>
|
||||
<Input
|
||||
label="Margen mínimo de alerta (%)"
|
||||
type="number"
|
||||
value={String(form.margen_minimo_alerta)}
|
||||
onChange={(e) => set('margen_minimo_alerta', Number(e.target.value) as unknown as number)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<p className="font-medium text-theme-heading">Proveedores</p>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
{form.activo ? (
|
||||
<ProveedoresPanel />
|
||||
) : (
|
||||
<p className="text-sm text-theme-muted">
|
||||
Activa Compras y guarda para administrar proveedores desde aquí. También puedes
|
||||
hacerlo desde Compras › Proveedores.
|
||||
</p>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComprasPanel;
|
||||
@@ -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<LayoutProps> = ({ 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<string[]>(['Clínica']);
|
||||
const chatUnread = useChatUnread();
|
||||
|
||||
@@ -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<LayoutProps> = ({ 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
|
||||
|
||||
179
frontend/src/components/compras/CompraDetalleModal.tsx
Normal file
179
frontend/src/components/compras/CompraDetalleModal.tsx
Normal file
@@ -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<Props> = ({
|
||||
isOpen, onClose, compra, puedeEditar, puedeRecibir, puedePagar, puedeCancelar, puedeCrear,
|
||||
onEdit, onEnviar, onRecibir, onPagar, onCancelar,
|
||||
}) => {
|
||||
if (!compra) return null;
|
||||
const meta = ESTADO_META[compra.estado];
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Compra ${compra.folio}`}
|
||||
maxWidth="full"
|
||||
footer={(
|
||||
<div className="flex flex-wrap justify-end gap-2 w-full">
|
||||
{puedeEditar && ['borrador', 'enviada'].includes(compra.estado) && (
|
||||
<Button variant="outline" onClick={() => onEdit(compra)}><Pencil size={14} className="mr-1.5" />Editar</Button>
|
||||
)}
|
||||
{puedeCrear && compra.estado === 'borrador' && (
|
||||
<Button variant="outline" onClick={() => onEnviar(compra)}><Send size={14} className="mr-1.5" />Enviar</Button>
|
||||
)}
|
||||
{puedeRecibir && compra.puede_recibir && (
|
||||
<Button onClick={() => onRecibir(compra)}><PackageCheck size={14} className="mr-1.5" />Recibir</Button>
|
||||
)}
|
||||
{puedePagar && compra.estado !== 'cancelada' && compra.saldo > 0 && (
|
||||
<Button onClick={() => onPagar(compra)}><CreditCard size={14} className="mr-1.5" />Pagar</Button>
|
||||
)}
|
||||
{puedeCancelar && compra.puede_cancelar && (
|
||||
<Button variant="danger" onClick={() => onCancelar(compra)}><Ban size={14} className="mr-1.5" />Cancelar</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
<span className="text-sm text-theme-heading">{compra.proveedor}</span>
|
||||
<span className="text-xs text-theme-muted">Fecha {compra.fecha}</span>
|
||||
{compra.fecha_entrega_esperada && <span className="text-xs text-theme-muted">Entrega esperada {compra.fecha_entrega_esperada}</span>}
|
||||
<span className="text-xs text-theme-muted">Moneda {compra.moneda}</span>
|
||||
{compra.recibida_at && <span className="text-xs text-theme-muted">Recibida {compra.recibida_at}</span>}
|
||||
</div>
|
||||
|
||||
{compra.cancel_reason && (
|
||||
<p className="text-xs text-rose-700">Cancelada: {compra.cancel_reason}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-[11px] text-theme-muted">Subtotal</p>
|
||||
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.subtotal, compra.moneda)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-[11px] text-theme-muted">Impuestos</p>
|
||||
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.impuestos, compra.moneda)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-[11px] text-theme-muted">Total</p>
|
||||
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.total, compra.moneda)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-theme-bg p-3">
|
||||
<p className="text-[11px] text-theme-muted">Pagado / Saldo</p>
|
||||
<p className="text-theme-heading font-medium tabular-nums">
|
||||
{fmtMoney(compra.pagado, compra.moneda)} <span className="text-theme-muted">/ {fmtMoney(compra.saldo, compra.moneda)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Líneas</p>
|
||||
<div className="overflow-x-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Artículo</th>
|
||||
<th className="text-right px-3 py-2">Pedida</th>
|
||||
<th className="text-right px-3 py-2">Recibida</th>
|
||||
<th className="text-right px-3 py-2">Pendiente</th>
|
||||
<th className="text-right px-3 py-2">Costo</th>
|
||||
<th className="text-right px-3 py-2">Subtotal</th>
|
||||
<th className="text-left px-3 py-2">Lote / Cad.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{(compra.lineas || []).map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="px-3 py-2 text-theme-heading">{l.item}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pedida)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_recibida)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pendiente)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario, compra.moneda)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.subtotal, compra.moneda)}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-theme-muted">
|
||||
{l.lote || '—'}{l.fecha_caducidad ? ` · ${l.fecha_caducidad}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Recepciones</p>
|
||||
{(compra.recepciones || []).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin recepciones todavía.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
|
||||
{(compra.recepciones || []).map((r) => (
|
||||
<li key={r.id} className="px-3 py-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-theme-heading">{r.fecha}</span>
|
||||
<span className="text-theme-muted tabular-nums">{fmtNum(r.total_unidades)} u · {fmtMoney(r.total_costo, compra.moneda)}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-theme-muted">{r.usuario || '—'}{r.notas ? ` · ${r.notas}` : ''}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{r.lineas.map((ln) => (
|
||||
<li key={ln.id} className="text-[11px] text-theme-muted">
|
||||
{ln.item}: {fmtNum(ln.cantidad)} u{ln.lote ? ` · lote ${ln.lote}` : ''}{ln.fecha_caducidad ? ` · cad. ${ln.fecha_caducidad}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Pagos</p>
|
||||
{(compra.pagos || []).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin pagos registrados.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
|
||||
{(compra.pagos || []).map((p) => (
|
||||
<li key={p.id} className="px-3 py-2 text-sm flex justify-between">
|
||||
<div>
|
||||
<p className="text-theme-heading">{fmtMoney(p.monto, p.moneda)}</p>
|
||||
<p className="text-[11px] text-theme-muted">{p.fecha} · {p.metodo_label}{p.referencia ? ` · ${p.referencia}` : ''}</p>
|
||||
</div>
|
||||
{p.moneda !== compra.moneda && (
|
||||
<span className="text-[11px] text-theme-muted tabular-nums">= {fmtMoney(p.monto_en_compra, compra.moneda)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{compra.notas && <p className="text-sm text-theme-muted">Observaciones: {compra.notas}</p>}
|
||||
<Badge variant="info">Los pagos a proveedores son egresos y no se mezclan con los cobros a pacientes.</Badge>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompraDetalleModal;
|
||||
240
frontend/src/components/compras/CompraFormModal.tsx
Normal file
240
frontend/src/components/compras/CompraFormModal.tsx
Normal file
@@ -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<Props> = ({ isOpen, onClose, compra, onDone }) => {
|
||||
const [proveedores, setProveedores] = useState<Proveedor[]>([]);
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [partnerId, setPartnerId] = useState('');
|
||||
const [fecha, setFecha] = useState(hoy());
|
||||
const [entrega, setEntrega] = useState('');
|
||||
const [moneda, setMoneda] = useState<MonedaCompra>('MXN');
|
||||
const [descuento, setDescuento] = useState('0');
|
||||
const [referencia, setReferencia] = useState('');
|
||||
const [notas, setNotas] = useState('');
|
||||
const [lineas, setLineas] = useState<LineaForm[]>([{ ...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<LineaForm>) =>
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={compra ? `Editar compra ${compra.folio}` : 'Nueva orden de compra'}
|
||||
maxWidth="full"
|
||||
footer={(
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="text-sm text-theme-muted">
|
||||
Subtotal <strong className="text-theme-heading">{fmtMoney(subtotal, moneda)}</strong>
|
||||
{desc > 0 && <> · Descuento <strong className="text-theme-heading">{fmtMoney(desc, moneda)}</strong></>}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>Guardar</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Select
|
||||
label="Proveedor"
|
||||
options={[{ value: '', label: 'Selecciona…' }, ...proveedores.map((p) => ({ value: String(p.id), label: p.name }))]}
|
||||
value={partnerId}
|
||||
onChange={(e) => setPartnerId(e.target.value)}
|
||||
/>
|
||||
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
|
||||
<Input label="Entrega esperada" type="date" value={entrega} onChange={(e) => setEntrega(e.target.value)} />
|
||||
<Select label="Moneda" options={MONEDA_OPTIONS} value={moneda} onChange={(e) => setMoneda(e.target.value as MonedaCompra)} />
|
||||
<Input label="Descuento global" type="number" value={descuento} onChange={(e) => setDescuento(e.target.value)} />
|
||||
<Input label="Referencia / factura" value={referencia} onChange={(e) => setReferencia(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 min-w-[200px]">Artículo</th>
|
||||
<th className="text-right px-3 py-2 w-24">Cantidad</th>
|
||||
<th className="text-right px-3 py-2 w-28">Costo unit.</th>
|
||||
<th className="text-right px-3 py-2 w-24">Desc.</th>
|
||||
<th className="text-left px-3 py-2 w-32">Lote</th>
|
||||
<th className="text-left px-3 py-2 w-40">Caducidad</th>
|
||||
<th className="text-right px-3 py-2 w-24">Subtotal</th>
|
||||
<th className="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{lineas.map((l, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
options={[{ value: '', label: 'Selecciona…' }, ...items.map((it) => ({ value: String(it.id), label: it.name }))]}
|
||||
value={l.item_id}
|
||||
onChange={(e) => onPickItem(idx, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input type="number" value={l.cantidad_pedida} onChange={(e) => setLinea(idx, { cantidad_pedida: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input type="number" value={l.costo_unitario} onChange={(e) => setLinea(idx, { costo_unitario: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input type="number" value={l.descuento} onChange={(e) => setLinea(idx, { descuento: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(subtotalLinea(l), moneda)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"
|
||||
onClick={() => setLineas((prev) => prev.length > 1 ? prev.filter((_, i) => i !== idx) : prev)}
|
||||
title="Quitar línea"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setLineas((prev) => [...prev, { ...emptyLinea }])}>
|
||||
<Plus size={14} className="mr-1.5" />Agregar línea
|
||||
</Button>
|
||||
|
||||
<TextArea label="Observaciones" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
|
||||
<p className="text-[11px] text-theme-muted">
|
||||
Los impuestos se calculan según la configuración de impuestos de la clínica. Si no están
|
||||
activos, la compra queda sin impuesto.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompraFormModal;
|
||||
148
frontend/src/components/compras/CuentasPorPagarPanel.tsx
Normal file
148
frontend/src/components/compras/CuentasPorPagarPanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CreditCard, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, EmptyState, toast } from '../ui';
|
||||
import { odooApi, type CuentaPorPagar } from '../../services/odoo';
|
||||
import { fmtMoney } from './shared';
|
||||
import PagoCompraModal from './PagoCompraModal';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
|
||||
const CuentasPorPagarPanel: FC = () => {
|
||||
const { can } = useAuth();
|
||||
const [proveedores, setProveedores] = useState<CuentaPorPagar[]>([]);
|
||||
const [buckets, setBuckets] = useState<{ key: string; label: string }[]>([]);
|
||||
const [totales, setTotales] = useState<{
|
||||
saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [abierto, setAbierto] = useState<number | null>(null);
|
||||
const [pago, setPago] = useState<{ id: number; folio: string; saldo: number; moneda: 'MXN' | 'USD' } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getCuentasPorPagar();
|
||||
if (res.status === 'success') {
|
||||
setProveedores(res.proveedores || []);
|
||||
setBuckets(res.buckets || []);
|
||||
setTotales(res.totales || null);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudieron cargar las cuentas por pagar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-theme-muted">
|
||||
{totales ? `${totales.proveedores} proveedor(es) · ${totales.compras} compra(s) con saldo` : ''}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Total por pagar</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.saldo || 0)}</p>
|
||||
</Card.Body></Card>
|
||||
{buckets.map((b) => (
|
||||
<Card key={b.key}><Card.Body>
|
||||
<p className="text-xs text-theme-muted">{b.label}</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.por_bucket?.[b.key] || 0)}</p>
|
||||
</Card.Body></Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card><Card.Body><p className="text-sm text-theme-muted">Cargando…</p></Card.Body></Card>
|
||||
) : proveedores.length === 0 ? (
|
||||
<Card><Card.Body>
|
||||
<EmptyState title="Sin cuentas por pagar" subtitle="Todas las compras están pagadas o no hay compras con saldo." />
|
||||
</Card.Body></Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{proveedores.map((p) => (
|
||||
<Card key={p.partner_id}>
|
||||
<Card.Body noPadding>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-theme-bg"
|
||||
onClick={() => setAbierto(abierto === p.partner_id ? null : p.partner_id)}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="text-theme-heading font-medium">{p.proveedor}</p>
|
||||
<p className="text-[11px] text-theme-muted">{p.compras} compra(s) · crédito {p.dias_credito} días</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{buckets.map((b) => (p.buckets[b.key] ? (
|
||||
<span key={b.key} className="hidden md:inline text-[11px] text-theme-muted">
|
||||
{b.label}: <strong className="text-theme-heading tabular-nums">{fmtMoney(p.buckets[b.key])}</strong>
|
||||
</span>
|
||||
) : null))}
|
||||
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(p.saldo)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{abierto === p.partner_id && (
|
||||
<div className="border-t border-theme-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Folio</th>
|
||||
<th className="text-left px-4 py-2">Fecha</th>
|
||||
<th className="text-left px-4 py-2">Vence</th>
|
||||
<th className="text-right px-4 py-2">Días</th>
|
||||
<th className="text-right px-4 py-2">Total</th>
|
||||
<th className="text-right px-4 py-2">Pagado</th>
|
||||
<th className="text-right px-4 py-2">Saldo</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{p.detalle.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-4 py-2 text-theme-heading">{d.folio}</td>
|
||||
<td className="px-4 py-2 text-theme-muted">{d.fecha}</td>
|
||||
<td className="px-4 py-2 text-theme-muted">{d.fecha_vencimiento}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{d.dias}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.total, d.moneda)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.pagado, d.moneda)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-medium">{fmtMoney(d.saldo, d.moneda)}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{can('compras.pagar') && (
|
||||
<Button size="sm" variant="outline" onClick={() => setPago({ id: d.id, folio: d.folio, saldo: d.saldo, moneda: d.moneda })}>
|
||||
<CreditCard size={13} className="mr-1.5" />Pagar
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PagoCompraModal
|
||||
isOpen={!!pago}
|
||||
onClose={() => setPago(null)}
|
||||
compraId={pago?.id ?? null}
|
||||
folio={pago?.folio || ''}
|
||||
saldo={pago?.saldo || 0}
|
||||
moneda={pago?.moneda || 'MXN'}
|
||||
onDone={() => load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CuentasPorPagarPanel;
|
||||
88
frontend/src/components/compras/PagoCompraModal.tsx
Normal file
88
frontend/src/components/compras/PagoCompraModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Button, Input, TextArea, Select, toast } from '../ui';
|
||||
import { odooApi, type MonedaCompra, type MetodoPagoCompra } from '../../services/odoo';
|
||||
import { METODO_OPTIONS, MONEDA_OPTIONS, fmtMoney } from './shared';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
compraId: number | null;
|
||||
folio: string;
|
||||
saldo: number;
|
||||
moneda: MonedaCompra;
|
||||
onDone: (compraId: number) => void;
|
||||
}
|
||||
|
||||
const PagoCompraModal: FC<Props> = ({ isOpen, onClose, compraId, folio, saldo, moneda, onDone }) => {
|
||||
const [monto, setMonto] = useState('');
|
||||
const [monedaPago, setMonedaPago] = useState<MonedaCompra>(moneda);
|
||||
const [metodo, setMetodo] = useState<MetodoPagoCompra>('transferencia');
|
||||
const [referencia, setReferencia] = useState('');
|
||||
const [notas, setNotas] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMonto(saldo > 0 ? String(saldo) : '');
|
||||
setMonedaPago(moneda);
|
||||
setMetodo('transferencia');
|
||||
setReferencia('');
|
||||
setNotas('');
|
||||
}
|
||||
}, [isOpen, saldo, moneda]);
|
||||
|
||||
const guardar = async () => {
|
||||
if (!compraId) return;
|
||||
const m = parseFloat(monto);
|
||||
if (!m || m <= 0) { toast.error('Ingresa un monto válido'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await odooApi.pagarCompra(compraId, {
|
||||
monto: m, moneda: monedaPago, metodo, referencia, notas,
|
||||
});
|
||||
toast.success('Pago registrado');
|
||||
onDone(compraId);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo registrar el pago');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Pagar compra ${folio}`}
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>Registrar pago</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-theme-bg p-3 text-sm">
|
||||
<span className="text-theme-muted">Saldo pendiente: </span>
|
||||
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(saldo, moneda)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Monto" type="number" value={monto} onChange={(e) => setMonto(e.target.value)} />
|
||||
<Select label="Moneda" options={MONEDA_OPTIONS} value={monedaPago} onChange={(e) => setMonedaPago(e.target.value as MonedaCompra)} />
|
||||
<Select label="Método" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value as MetodoPagoCompra)} />
|
||||
<Input label="Referencia" value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="Folio, transferencia…" />
|
||||
</div>
|
||||
<TextArea label="Notas" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
|
||||
<p className="text-[11px] text-theme-muted">
|
||||
Este pago es un EGRESO a proveedor: no se mezcla con los cobros a pacientes
|
||||
(`skeen.pago`), por lo que no aparece en cortes de caja ni en estados de cuenta.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PagoCompraModal;
|
||||
257
frontend/src/components/compras/ProveedoresPanel.tsx
Normal file
257
frontend/src/components/compras/ProveedoresPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, Search, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, Input, TextArea, Modal, toast, EmptyState } from '../ui';
|
||||
import { odooApi, type Proveedor } from '../../services/odoo';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useClinicSettings } from '../../lib/clinic-settings';
|
||||
import { fmtMoney } from './shared';
|
||||
|
||||
interface ProveedorForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
contacto: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
vat: string;
|
||||
dias_credito: string;
|
||||
banco: string;
|
||||
cuenta_pago: string;
|
||||
notas: string;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
const empty: ProveedorForm = {
|
||||
name: '', contacto: '', phone: '', email: '', vat: '', dias_credito: '0',
|
||||
banco: '', cuenta_pago: '', notas: '', activo: true,
|
||||
};
|
||||
|
||||
const ProveedoresPanel: FC = () => {
|
||||
const { can } = useAuth();
|
||||
const { compras } = useClinicSettings();
|
||||
const puedeEditar = can('proveedores.editar');
|
||||
const [rows, setRows] = useState<Proveedor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [q, setQ] = useState('');
|
||||
const [incluirInactivos, setIncluirInactivos] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<ProveedorForm>(empty);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [baja, setBaja] = useState<Proveedor | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getProveedores({ q: q || undefined, includeInactive: incluirInactivos });
|
||||
if (res.status === 'success') setRows(res.proveedores || []);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudieron cargar los proveedores');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, incluirInactivos]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const abrirNuevo = () => {
|
||||
setForm({ ...empty, dias_credito: String(compras?.dias_credito_default ?? 0) });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const abrirEditar = (p: Proveedor) => {
|
||||
setForm({
|
||||
id: p.id, name: p.name, contacto: p.contacto || '', phone: p.phone || '',
|
||||
email: p.email || '', vat: p.vat || '', dias_credito: String(p.dias_credito || 0),
|
||||
banco: p.banco || '', cuenta_pago: p.cuenta_pago || '', notas: p.notas || '',
|
||||
activo: p.activo,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const guardar = async () => {
|
||||
if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
skeen_contacto: form.contacto, phone: form.phone, email: form.email, vat: form.vat,
|
||||
dias_credito: parseInt(form.dias_credito || '0', 10) || 0,
|
||||
skeen_banco: form.banco, skeen_cuenta_pago: form.cuenta_pago,
|
||||
skeen_notas_proveedor: form.notas, activo: form.activo,
|
||||
};
|
||||
if (form.id) await odooApi.updateProveedor(form.id, payload);
|
||||
else await odooApi.createProveedor(payload);
|
||||
toast.success(form.id ? 'Proveedor actualizado' : 'Proveedor creado');
|
||||
setFormOpen(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo guardar el proveedor');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActivo = async (p: Proveedor) => {
|
||||
try {
|
||||
await odooApi.updateProveedor(p.id, { activo: !p.activo });
|
||||
toast.success(p.activo ? 'Proveedor desactivado' : 'Proveedor reactivado');
|
||||
load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo cambiar el estado');
|
||||
}
|
||||
};
|
||||
|
||||
const eliminar = async () => {
|
||||
if (!baja) return;
|
||||
try {
|
||||
const res = await odooApi.deleteProveedor(baja.id);
|
||||
if (res.status === 'success') toast.success('Proveedor eliminado');
|
||||
else toast.error(res.message || 'No se pudo eliminar');
|
||||
setBaja(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo eliminar el proveedor');
|
||||
setBaja(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Buscar por nombre, correo, teléfono o RFC"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-theme-muted whitespace-nowrap">
|
||||
<input type="checkbox" checked={incluirInactivos} onChange={(e) => setIncluirInactivos(e.target.checked)} />
|
||||
Incluir inactivos
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||
{puedeEditar && (
|
||||
<Button onClick={abrirNuevo}><Plus size={16} className="mr-1.5" />Nuevo proveedor</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body noPadding>
|
||||
{loading ? (
|
||||
<div className="p-6 text-sm text-theme-muted">Cargando proveedores…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState title="Sin proveedores" subtitle="Da de alta tu primer proveedor para comenzar a comprar." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Proveedor</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Contacto</th>
|
||||
<th className="text-left px-4 py-3 hidden lg:table-cell">Teléfono / correo</th>
|
||||
<th className="text-right px-4 py-3">Crédito</th>
|
||||
<th className="text-right px-4 py-3">Compras</th>
|
||||
<th className="text-right px-4 py-3">Saldo</th>
|
||||
<th className="text-right px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((p) => (
|
||||
<tr key={p.id} className={p.activo ? '' : 'opacity-60'}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-theme-heading font-medium">{p.name}</p>
|
||||
{p.vat && <p className="text-[11px] text-theme-muted">{p.vat}</p>}
|
||||
{!p.activo && <span className="text-[11px] text-rose-600">Inactivo</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{p.contacto || '—'}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden lg:table-cell">
|
||||
<span className="block">{p.phone || '—'}</span>
|
||||
<span className="block text-[11px]">{p.email || ''}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.compras_count}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{puedeEditar ? (
|
||||
<>
|
||||
<button type="button" title="Editar" onClick={() => abrirEditar(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button type="button" title={p.activo ? 'Desactivar' : 'Reactivar'} onClick={() => toggleActivo(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
|
||||
<Power size={15} />
|
||||
</button>
|
||||
{p.compras_count === 0 && (
|
||||
<button type="button" title="Eliminar" onClick={() => setBaja(p)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : <span className="text-[11px] text-theme-muted">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={form.id ? 'Editar proveedor' : 'Nuevo proveedor'}
|
||||
maxWidth="lg"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>Guardar</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Nombre / razón social" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<Input label="Contacto de compras" value={form.contacto} onChange={(e) => setForm({ ...form, contacto: e.target.value })} />
|
||||
<Input label="Teléfono" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<Input label="Correo" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
<Input label="RFC / identificación" value={form.vat} onChange={(e) => setForm({ ...form, vat: e.target.value })} />
|
||||
<Input label="Días de crédito" type="number" value={form.dias_credito} onChange={(e) => setForm({ ...form, dias_credito: e.target.value })} />
|
||||
<Input label="Banco" value={form.banco} onChange={(e) => setForm({ ...form, banco: e.target.value })} />
|
||||
<Input label="Cuenta / CLABE" value={form.cuenta_pago} onChange={(e) => setForm({ ...form, cuenta_pago: e.target.value })} />
|
||||
<div className="sm:col-span-2">
|
||||
<TextArea label="Notas" rows={3} value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={!!baja}
|
||||
onClose={() => setBaja(null)}
|
||||
title="Eliminar proveedor"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setBaja(null)}>Cancelar</Button>
|
||||
<Button variant="danger" onClick={eliminar}>Eliminar</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-theme-muted">
|
||||
¿Eliminar a <strong className="text-theme-heading">{baja?.name}</strong>? Solo se permite
|
||||
si no tiene compras registradas.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProveedoresPanel;
|
||||
191
frontend/src/components/compras/RecepcionModal.tsx
Normal file
191
frontend/src/components/compras/RecepcionModal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Modal, Button, Input, TextArea, toast } from '../ui';
|
||||
import { odooApi, type Compra } from '../../services/odoo';
|
||||
import { useClinicSettings } from '../../lib/clinic-settings';
|
||||
import { fmtNum, fmtMoney } from './shared';
|
||||
|
||||
interface LineaForm {
|
||||
linea_id: number;
|
||||
item: string;
|
||||
unit: string;
|
||||
kind: string;
|
||||
pedida: number;
|
||||
recibida: number;
|
||||
pendiente: number;
|
||||
costo_unitario: number;
|
||||
cantidad: string;
|
||||
lote: string;
|
||||
fecha_caducidad: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
compra: Compra | null;
|
||||
onDone: (compraId: number) => void;
|
||||
}
|
||||
|
||||
const RecepcionModal: FC<Props> = ({ isOpen, onClose, compra, onDone }) => {
|
||||
const { compras } = useClinicSettings();
|
||||
const [lineas, setLineas] = useState<LineaForm[]>([]);
|
||||
const [notas, setNotas] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && compra) {
|
||||
setLineas((compra.lineas || []).map((l) => ({
|
||||
linea_id: l.id,
|
||||
item: l.item,
|
||||
unit: l.unit,
|
||||
kind: l.kind,
|
||||
pedida: l.cantidad_pedida,
|
||||
recibida: l.cantidad_recibida,
|
||||
pendiente: l.cantidad_pendiente,
|
||||
costo_unitario: l.costo_unitario,
|
||||
cantidad: String(l.cantidad_pendiente),
|
||||
lote: l.lote || '',
|
||||
fecha_caducidad: l.fecha_caducidad || '',
|
||||
})));
|
||||
setNotas('');
|
||||
}
|
||||
}, [isOpen, compra]);
|
||||
|
||||
const setLinea = (idx: number, patch: Partial<LineaForm>) =>
|
||||
setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)));
|
||||
|
||||
const diferencias = lineas.filter((l) => {
|
||||
const c = parseFloat(l.cantidad) || 0;
|
||||
return c > 0 && Math.abs(c - l.pendiente) > 1e-6;
|
||||
});
|
||||
const excesos = lineas.filter((l) => (parseFloat(l.cantidad) || 0) > l.pendiente + 1e-6);
|
||||
const totalRecibir = lineas.reduce((s, l) => s + (parseFloat(l.cantidad) || 0), 0);
|
||||
|
||||
const confirmar = async () => {
|
||||
if (!compra) return;
|
||||
const payload = lineas
|
||||
.filter((l) => (parseFloat(l.cantidad) || 0) > 0)
|
||||
.map((l) => ({
|
||||
linea_id: l.linea_id,
|
||||
cantidad: parseFloat(l.cantidad) || 0,
|
||||
lote: l.lote,
|
||||
fecha_caducidad: l.fecha_caducidad || null,
|
||||
costo_unitario: l.costo_unitario,
|
||||
}));
|
||||
if (payload.length === 0) { toast.error('Indica al menos una cantidad a recibir'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await odooApi.recibirCompra(compra.id, { lineas: payload, notas });
|
||||
toast.success('Recepción registrada: stock y costo actualizados');
|
||||
onDone(compra.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo registrar la recepción');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Recibir mercancía · ${compra?.folio || ''}`}
|
||||
maxWidth="full"
|
||||
footer={(
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<span className="text-xs text-theme-muted">
|
||||
{fmtNum(totalRecibir)} unidades por recibir
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={confirmar} loading={saving}>Confirmar recepción</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3 text-xs text-amber-800 flex gap-2">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Esta acción afecta el <strong>stock</strong> y, si la clínica lo tiene activado, el
|
||||
<strong> costo promedio</strong> del artículo. Se permiten recepciones parciales:
|
||||
lo que no recibas quedará pendiente para una recepción posterior.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Artículo</th>
|
||||
<th className="text-right px-3 py-2">Pedida</th>
|
||||
<th className="text-right px-3 py-2">Recibida</th>
|
||||
<th className="text-right px-3 py-2">Pendiente</th>
|
||||
<th className="text-right px-3 py-2 w-28">Recibir</th>
|
||||
<th className="text-left px-3 py-2 w-32">Lote</th>
|
||||
<th className="text-left px-3 py-2 w-40">Caducidad</th>
|
||||
<th className="text-right px-3 py-2">Costo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{lineas.map((l, idx) => {
|
||||
const c = parseFloat(l.cantidad) || 0;
|
||||
const dif = c > 0 && Math.abs(c - l.pendiente) > 1e-6;
|
||||
const exceso = c > l.pendiente + 1e-6;
|
||||
return (
|
||||
<tr key={l.linea_id}>
|
||||
<td className="px-3 py-2">
|
||||
<p className="text-theme-heading">{l.item}</p>
|
||||
<p className="text-[11px] text-theme-muted">{l.unit}{l.kind === 'consumible' ? ' · consumible' : ''}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pedida)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-theme-muted">{fmtNum(l.recibida)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pendiente)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={l.cantidad}
|
||||
onChange={(e) => setLinea(idx, { cantidad: e.target.value })}
|
||||
inputClassName={exceso ? 'border-rose-300' : dif ? 'border-amber-300' : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{excesos.length > 0 && (
|
||||
<p className="text-xs text-rose-600">
|
||||
Hay líneas con cantidad mayor a lo pendiente; se ajustarán al pendiente.
|
||||
</p>
|
||||
)}
|
||||
{diferencias.length > 0 && excesos.length === 0 && (
|
||||
<p className="text-xs text-amber-700">
|
||||
Diferencias contra lo pedido en {diferencias.length} línea(s): se registrará una recepción parcial.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TextArea label="Notas de la recepción" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
|
||||
{compras?.permitir_recepcion_parcial === false && (
|
||||
<p className="text-[11px] text-theme-muted">
|
||||
Esta clínica no permite recepciones parciales: cada línea debe recibirse completa.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecepcionModal;
|
||||
29
frontend/src/components/compras/shared.ts
Normal file
29
frontend/src/components/compras/shared.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { EstadoCompra } from '../../services/odoo';
|
||||
|
||||
export const fmtMoney = (value: number, currency = 'MXN') =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency', currency, maximumFractionDigits: 2,
|
||||
}).format(value || 0);
|
||||
|
||||
export const fmtNum = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { maximumFractionDigits: 2 }).format(value || 0);
|
||||
|
||||
export const ESTADO_META: Record<EstadoCompra, { label: string; cls: string }> = {
|
||||
borrador: { label: 'Borrador', cls: 'bg-slate-200 text-slate-700' },
|
||||
enviada: { label: 'Enviada', cls: 'bg-sky-200 text-sky-900' },
|
||||
recibida_parcial: { label: 'Recibida parcial', cls: 'bg-amber-200 text-amber-900' },
|
||||
recibida: { label: 'Recibida', cls: 'bg-theme-success text-theme-heading' },
|
||||
cancelada: { label: 'Cancelada', cls: 'bg-rose-200 text-rose-900' },
|
||||
};
|
||||
|
||||
export const METODO_OPTIONS = [
|
||||
{ value: 'transferencia', label: 'Transferencia' },
|
||||
{ value: 'efectivo', label: 'Efectivo' },
|
||||
{ value: 'tarjeta', label: 'Tarjeta' },
|
||||
{ value: 'otro', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const MONEDA_OPTIONS = [
|
||||
{ value: 'MXN', label: 'MXN (pesos)' },
|
||||
{ value: 'USD', label: 'USD (dólares)' },
|
||||
];
|
||||
185
frontend/src/components/reportes/ComprasReport.tsx
Normal file
185
frontend/src/components/reportes/ComprasReport.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshCw, Download } from 'lucide-react';
|
||||
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { odooApi, type ComprasReportResponse } from '../../services/odoo';
|
||||
import { useReportRange } from '../../lib/reportRange';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { fmtMoney } from '../compras/shared';
|
||||
|
||||
const ComprasReport: FC = () => {
|
||||
const { start, end } = useReportRange('compras');
|
||||
const { brand } = useAuth();
|
||||
const [data, setData] = useState<ComprasReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getComprasReport(start, end);
|
||||
if (res.status === 'success') setData(res);
|
||||
else toast.error(res.message || 'No se pudo cargar el reporte');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al cargar el reporte de compras');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `reporte-compras-${data.start}_${data.end}`,
|
||||
sheetName: 'Compras',
|
||||
title: `Reporte de compras · ${brand.nombre}`,
|
||||
subtitle: `Periodo ${data.start} a ${data.end}`,
|
||||
columns: [
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Compras', key: 'compras', format: 'number' },
|
||||
{ header: 'Total', key: 'total', format: 'currency' },
|
||||
{ header: 'Pagado', key: 'pagado', format: 'currency' },
|
||||
{ header: 'Saldo', key: 'saldo', format: 'currency' },
|
||||
],
|
||||
rows: [
|
||||
{ concepto: 'Totales', compras: data.totales.compras, total: data.totales.total, pagado: data.totales.pagado, saldo: data.totales.saldo },
|
||||
...data.por_proveedor.map((p) => ({ concepto: p.proveedor, compras: p.compras, total: p.total, pagado: p.pagado, saldo: p.saldo })),
|
||||
],
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch {
|
||||
toast.error('No se pudo exportar');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
||||
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de compras." /></Card.Body></Card>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Compras</p><p className="text-2xl font-heading text-theme-heading">{data.totales.compras}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Subtotal</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.subtotal)}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Impuestos</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.impuestos)}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Total</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.total)}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Saldo</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.saldo)}</p></Card.Body></Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Header><p className="font-medium text-theme-heading">Gasto por proveedor</p></Card.Header>
|
||||
<Card.Body noPadding>
|
||||
{data.por_proveedor.length === 0 ? (
|
||||
<div className="p-6"><p className="text-sm text-theme-muted">Sin compras en el periodo.</p></div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Proveedor</th>
|
||||
<th className="text-right px-4 py-3">Compras</th>
|
||||
<th className="text-right px-4 py-3">Total</th>
|
||||
<th className="text-right px-4 py-3">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.por_proveedor.map((p) => (
|
||||
<tr key={p.partner_id}>
|
||||
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.total)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card.Header><p className="font-medium text-theme-heading">Gasto por artículo</p></Card.Header>
|
||||
<Card.Body noPadding>
|
||||
{data.por_articulo.length === 0 ? (
|
||||
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos comprados en el periodo.</p></div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-right px-4 py-3">Pedido</th>
|
||||
<th className="text-right px-4 py-3">Recibido</th>
|
||||
<th className="text-right px-4 py-3">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.por_articulo.map((a) => (
|
||||
<tr key={a.item_id}>
|
||||
<td className="px-4 py-3 text-theme-heading">{a.item}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_pedida}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_recibida}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.subtotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Header><p className="font-medium text-theme-heading">Cuentas por pagar</p></Card.Header>
|
||||
<Card.Body noPadding>
|
||||
{data.cuentas_por_pagar.proveedores.length === 0 ? (
|
||||
<div className="p-6"><p className="text-sm text-theme-muted">Sin saldos pendientes.</p></div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Proveedor</th>
|
||||
<th className="text-right px-4 py-3">Compras</th>
|
||||
<th className="text-right px-4 py-3">Crédito</th>
|
||||
<th className="text-right px-4 py-3">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.cuentas_por_pagar.proveedores.map((p) => (
|
||||
<tr key={p.partner_id}>
|
||||
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-theme-bg font-medium">
|
||||
<td className="px-4 py-3 text-theme-heading" colSpan={3}>Total por pagar</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(data.cuentas_por_pagar.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComprasReport;
|
||||
144
frontend/src/components/reportes/MargenReport.tsx
Normal file
144
frontend/src/components/reportes/MargenReport.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshCw, Download, AlertTriangle } from 'lucide-react';
|
||||
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { odooApi, type MargenReportResponse } from '../../services/odoo';
|
||||
import { useReportRange } from '../../lib/reportRange';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { fmtMoney, fmtNum } from '../compras/shared';
|
||||
|
||||
const MargenReport: FC = () => {
|
||||
const { start, end } = useReportRange('margen');
|
||||
const { brand } = useAuth();
|
||||
const [data, setData] = useState<MargenReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [soloAlertas, setSoloAlertas] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getMargenReport(start, end);
|
||||
if (res.status === 'success') setData(res);
|
||||
else toast.error(res.message || 'No se pudo cargar el reporte');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al cargar el reporte de margen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `reporte-margen-${data.start}_${data.end}`,
|
||||
sheetName: 'Margen',
|
||||
title: `Reporte de margen · ${brand.nombre}`,
|
||||
subtitle: `Periodo ${data.start} a ${data.end}`,
|
||||
columns: [
|
||||
{ header: 'Artículo', key: 'item' },
|
||||
{ header: 'SKU', key: 'sku' },
|
||||
{ header: 'Precio', key: 'precio', format: 'currency' },
|
||||
{ header: 'Costo promedio', key: 'costo_promedio', format: 'currency' },
|
||||
{ header: 'Margen unitario', key: 'margen_unitario', format: 'currency' },
|
||||
{ header: 'Margen %', key: 'margen_pct', format: 'number' },
|
||||
{ header: 'Unidades vendidas', key: 'unidades_vendidas', format: 'number' },
|
||||
{ header: 'Ingresos', key: 'ingresos', format: 'currency' },
|
||||
{ header: 'Costo de ventas', key: 'costo_ventas', format: 'currency' },
|
||||
{ header: 'Margen total', key: 'margen_total', format: 'currency' },
|
||||
],
|
||||
rows: data.articulos as unknown as Record<string, unknown>[],
|
||||
totals: {
|
||||
item: 'Totales', sku: '', precio: '', costo_promedio: '', margen_unitario: '',
|
||||
margen_pct: data.totales.margen_pct, unidades_vendidas: '',
|
||||
ingresos: data.totales.ingresos, costo_ventas: data.totales.costo_ventas,
|
||||
margen_total: data.totales.margen,
|
||||
},
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch {
|
||||
toast.error('No se pudo exportar');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
||||
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de margen." /></Card.Body></Card>;
|
||||
|
||||
const filas = soloAlertas ? data.articulos.filter((a) => a.alerta) : data.articulos;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-theme-muted">
|
||||
<input type="checkbox" checked={soloAlertas} onChange={(e) => setSoloAlertas(e.target.checked)} />
|
||||
Solo alertas (< {data.margen_minimo_alerta}%)
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Ingresos por artículos</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.ingresos)}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Costo de ventas</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.costo_ventas)}</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted">Margen</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.margen)}</p><p className="text-[11px] text-theme-muted">{data.totales.margen_pct}%</p></Card.Body></Card>
|
||||
<Card><Card.Body><p className="text-xs text-theme-muted flex items-center gap-1"><AlertTriangle size={13} />En alerta</p><p className="text-xl font-heading text-theme-heading">{data.totales.alertas}</p></Card.Body></Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Header><p className="font-medium text-theme-heading">Margen por artículo</p></Card.Header>
|
||||
<Card.Body noPadding>
|
||||
{filas.length === 0 ? (
|
||||
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos en el periodo.</p></div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Artículo</th>
|
||||
<th className="text-right px-4 py-3">Precio</th>
|
||||
<th className="text-right px-4 py-3">Costo prom.</th>
|
||||
<th className="text-right px-4 py-3">Margen unit.</th>
|
||||
<th className="text-right px-4 py-3">Margen %</th>
|
||||
<th className="text-right px-4 py-3">Vendidas</th>
|
||||
<th className="text-right px-4 py-3">Ingresos</th>
|
||||
<th className="text-right px-4 py-3">Margen total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{filas.map((a) => (
|
||||
<tr key={a.item_id} className={a.alerta ? 'bg-amber-50' : ''}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-theme-heading">{a.item}</p>
|
||||
{a.sku && <p className="text-[11px] text-theme-muted">{a.sku}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.precio)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.costo_promedio)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.margen_unitario)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{a.margen_pct.toFixed(1)}%</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(a.unidades_vendidas)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.ingresos)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(a.margen_total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<p className="text-[11px] text-theme-muted">
|
||||
El margen usa el precio de venta sin impuesto menos el costo promedio del artículo. Si un
|
||||
artículo no tiene costo promedio cargado, se usa su costo unitario.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MargenReport;
|
||||
@@ -46,6 +46,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/pagos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
// Compras (apagado por defecto: la sección solo se pinta con
|
||||
// compras.activo=true). Se lista aquí para que canSee la reconozca.
|
||||
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/cortes': ['admin', 'recepcion', 'gerencia'],
|
||||
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
@@ -56,6 +59,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/configuracion': ['admin', 'gerencia'],
|
||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT } from '../services/odoo';
|
||||
|
||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||
@@ -12,6 +12,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
|
||||
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
|
||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||
});
|
||||
|
||||
let actual: ClinicSettings = normalizar(null);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ label: 'Pagos', href: '/pagos' },
|
||||
{ label: 'Monedero', href: '/monedero' },
|
||||
{ label: 'Inventario', href: '/inventario' },
|
||||
{ label: 'Compras', href: '/compras' },
|
||||
{ label: 'Cortes de Caja', href: '/cortes' },
|
||||
{ label: 'Reportes', href: '/reportes' },
|
||||
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
|
||||
|
||||
@@ -20,6 +20,8 @@ export const PERM_KEYS: string[] = [
|
||||
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
|
||||
'membresias.cancelar',
|
||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
];
|
||||
|
||||
@@ -46,6 +48,8 @@ const READ_PERM_KEYS = new Set([
|
||||
'notificaciones.ver',
|
||||
'formularios.ver',
|
||||
'membresias.ver',
|
||||
'compras.ver',
|
||||
'proveedores.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
@@ -71,6 +75,11 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
|
||||
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
|
||||
'membresias.cancelar': AG,
|
||||
'compras.ver': ALL, 'compras.crear': ['admin', 'recepcion', 'gerencia'],
|
||||
'compras.recibir': ['admin', 'recepcion', 'gerencia'],
|
||||
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
|
||||
'compras.editar': AG, 'compras.cancelar': AG,
|
||||
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||
};
|
||||
|
||||
|
||||
359
frontend/src/pages/Compras.tsx
Normal file
359
frontend/src/pages/Compras.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Plus, Search, RefreshCw, Eye, Pencil, Send, PackageCheck, CreditCard, Ban, Download, Truck } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, Modal, PageHeader, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi, type Compra, type CompraResumen } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { ESTADO_META, fmtMoney } from '../components/compras/shared';
|
||||
import CompraFormModal from '../components/compras/CompraFormModal';
|
||||
import CompraDetalleModal from '../components/compras/CompraDetalleModal';
|
||||
import RecepcionModal from '../components/compras/RecepcionModal';
|
||||
import PagoCompraModal from '../components/compras/PagoCompraModal';
|
||||
import CuentasPorPagarPanel from '../components/compras/CuentasPorPagarPanel';
|
||||
import ProveedoresPanel from '../components/compras/ProveedoresPanel';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'ordenes', label: 'Órdenes de compra' },
|
||||
{ key: 'cuentas', label: 'Cuentas por pagar' },
|
||||
{ key: 'proveedores', label: 'Proveedores' },
|
||||
] as const;
|
||||
|
||||
const CHIPS = [
|
||||
{ key: '', label: 'Todas' },
|
||||
{ key: 'borrador', label: 'Borradores' },
|
||||
{ key: 'enviada', label: 'Enviadas' },
|
||||
{ key: 'por_recibir', label: 'Por recibir' },
|
||||
{ key: 'por_pagar', label: 'Cuentas por pagar' },
|
||||
];
|
||||
|
||||
const Compras: FC = () => {
|
||||
const { can, brand } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = (searchParams.get('tab') || 'ordenes') as typeof TABS[number]['key'];
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
|
||||
const [rows, setRows] = useState<Compra[]>([]);
|
||||
const [resumen, setResumen] = useState<CompraResumen | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [estado, setEstado] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formCompra, setFormCompra] = useState<Compra | null>(null);
|
||||
const [detalle, setDetalle] = useState<Compra | null>(null);
|
||||
const [detalleOpen, setDetalleOpen] = useState(false);
|
||||
const [recepcion, setRecepcion] = useState<Compra | null>(null);
|
||||
const [pago, setPago] = useState<Compra | null>(null);
|
||||
const [cancelar, setCancelar] = useState<Compra | null>(null);
|
||||
const [cancelMotivo, setCancelMotivo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (tab !== 'ordenes') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await odooApi.getCompras({
|
||||
...(estado ? { estado } : {}),
|
||||
...(q ? { q } : {}),
|
||||
page_size: 100,
|
||||
});
|
||||
setRows(res.compras || []);
|
||||
setResumen(res.resumen || null);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudieron cargar las compras');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tab, estado, q]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const abrirDetalle = async (c: Compra) => {
|
||||
try {
|
||||
const res = await odooApi.getCompra(c.id);
|
||||
if (res.status === 'success') { setDetalle(res.compra); setDetalleOpen(true); }
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo abrir la compra');
|
||||
}
|
||||
};
|
||||
|
||||
const enviar = async (c: Compra) => {
|
||||
try {
|
||||
await odooApi.enviarCompra(c.id);
|
||||
toast.success('Compra enviada');
|
||||
load();
|
||||
if (detalle?.id === c.id) setDetalleOpen(false);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo enviar');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmarCancelar = async () => {
|
||||
if (!cancelar) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await odooApi.cancelarCompra(cancelar.id, cancelMotivo);
|
||||
toast.success('Compra cancelada');
|
||||
setCancelar(null);
|
||||
setCancelMotivo('');
|
||||
load();
|
||||
if (detalle?.id === cancelar.id) setDetalleOpen(false);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo cancelar');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportar = async () => {
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `compras-${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetName: 'Compras',
|
||||
title: `Compras · ${brand.nombre}`,
|
||||
subtitle: `Filtro: ${CHIPS.find((c) => c.key === estado)?.label || 'Todas'}`,
|
||||
columns: [
|
||||
{ header: 'Folio', key: 'folio' },
|
||||
{ header: 'Proveedor', key: 'proveedor' },
|
||||
{ header: 'Fecha', key: 'fecha', format: 'date' },
|
||||
{ header: 'Estado', key: 'estado_label' },
|
||||
{ header: 'Moneda', key: 'moneda' },
|
||||
{ header: 'Total', key: 'total', format: 'currency' },
|
||||
{ header: 'Pagado', key: 'pagado', format: 'currency' },
|
||||
{ header: 'Saldo', key: 'saldo', format: 'currency' },
|
||||
],
|
||||
rows: rows as unknown as Record<string, unknown>[],
|
||||
totals: {
|
||||
folio: '', proveedor: 'Totales', fecha: '', estado_label: '', moneda: '',
|
||||
total: rows.reduce((s, r) => s + r.total, 0),
|
||||
pagado: rows.reduce((s, r) => s + r.pagado, 0),
|
||||
saldo: rows.reduce((s, r) => s + r.saldo, 0),
|
||||
},
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch {
|
||||
toast.error('No se pudo exportar');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Compras" subtitle="Proveedores, órdenes, recepciones y cuentas por pagar">
|
||||
<PageHeader title="Compras" subtitle="Órdenes de compra, recepción de mercancía y cuentas por pagar">
|
||||
{tab === 'ordenes' && can('compras.crear') && (
|
||||
<Button onClick={() => { setFormCompra(null); setFormOpen(true); }}>
|
||||
<Plus size={16} className="mr-1.5" />Nueva compra
|
||||
</Button>
|
||||
)}
|
||||
{tab === 'ordenes' && can('reportes.exportar') && (
|
||||
<Button variant="outline" onClick={exportar} disabled={rows.length === 0}>
|
||||
<Download size={16} className="mr-1.5" />Exportar
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'cuentas' ? (
|
||||
<CuentasPorPagarPanel />
|
||||
) : tab === 'proveedores' ? (
|
||||
<ProveedoresPanel />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{resumen && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Gasto del mes</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.gasto_mes)}</p>
|
||||
<p className="text-[11px] text-theme-muted">{resumen.compras_mes} compra(s)</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Abiertas (por recibir)</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.abiertas}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Compras con saldo</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.por_pagar}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Saldo por pagar</p>
|
||||
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.saldo_total)}</p>
|
||||
</Card.Body></Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input className="pl-9" placeholder="Buscar por folio, proveedor o referencia" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.key || 'todas'}
|
||||
type="button"
|
||||
onClick={() => setEstado(c.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
|
||||
estado === c.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:text-theme-heading'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body noPadding>
|
||||
{loading ? (
|
||||
<div className="p-6 text-sm text-theme-muted">Cargando compras…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState title="Sin compras" subtitle="Crea una orden de compra para comenzar." icon={<Truck size={28} />} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Folio</th>
|
||||
<th className="text-left px-4 py-3">Proveedor</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Fecha</th>
|
||||
<th className="text-right px-4 py-3">Total</th>
|
||||
<th className="text-right px-4 py-3 hidden lg:table-cell">Pagado</th>
|
||||
<th className="text-right px-4 py-3">Saldo</th>
|
||||
<th className="text-left px-4 py-3">Estado</th>
|
||||
<th className="text-right px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((c) => {
|
||||
const meta = ESTADO_META[c.estado];
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td className="px-4 py-3 text-theme-heading font-medium">{c.folio}</td>
|
||||
<td className="px-4 py-3 text-theme-heading">{c.proveedor}</td>
|
||||
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{c.fecha}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(c.total, c.moneda)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums hidden lg:table-cell text-theme-muted">{fmtMoney(c.pagado, c.moneda)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(c.saldo, c.moneda)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button type="button" title="Ver detalle" onClick={() => abrirDetalle(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
|
||||
{can('compras.editar') && ['borrador', 'enviada'].includes(c.estado) && (
|
||||
<button type="button" title="Editar" onClick={() => { setFormCompra(c); setFormOpen(true); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
|
||||
)}
|
||||
{can('compras.crear') && c.estado === 'borrador' && (
|
||||
<button type="button" title="Enviar" onClick={() => enviar(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Send size={15} /></button>
|
||||
)}
|
||||
{can('compras.recibir') && c.puede_recibir && (
|
||||
<button type="button" title="Recibir" onClick={() => setRecepcion(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><PackageCheck size={15} /></button>
|
||||
)}
|
||||
{can('compras.pagar') && c.estado !== 'cancelada' && c.saldo > 0 && (
|
||||
<button type="button" title="Pagar" onClick={() => setPago(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><CreditCard size={15} /></button>
|
||||
)}
|
||||
{can('compras.cancelar') && c.puede_cancelar && (
|
||||
<button type="button" title="Cancelar" onClick={() => { setCancelar(c); setCancelMotivo(''); }} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Ban size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CompraFormModal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
compra={formCompra}
|
||||
onDone={() => { load(); if (detalleOpen) setDetalleOpen(false); }}
|
||||
/>
|
||||
|
||||
<CompraDetalleModal
|
||||
isOpen={detalleOpen}
|
||||
onClose={() => setDetalleOpen(false)}
|
||||
compra={detalle}
|
||||
puedeEditar={can('compras.editar')}
|
||||
puedeRecibir={can('compras.recibir')}
|
||||
puedePagar={can('compras.pagar')}
|
||||
puedeCancelar={can('compras.cancelar')}
|
||||
puedeCrear={can('compras.crear')}
|
||||
onEdit={(c) => { setDetalleOpen(false); setFormCompra(c); setFormOpen(true); }}
|
||||
onEnviar={(c) => enviar(c)}
|
||||
onRecibir={(c) => setRecepcion(c)}
|
||||
onPagar={(c) => setPago(c)}
|
||||
onCancelar={(c) => { setCancelar(c); setCancelMotivo(''); }}
|
||||
/>
|
||||
|
||||
<RecepcionModal
|
||||
isOpen={!!recepcion}
|
||||
onClose={() => setRecepcion(null)}
|
||||
compra={recepcion}
|
||||
onDone={() => { load(); }}
|
||||
/>
|
||||
|
||||
<PagoCompraModal
|
||||
isOpen={!!pago}
|
||||
onClose={() => setPago(null)}
|
||||
compraId={pago?.id ?? null}
|
||||
folio={pago?.folio || ''}
|
||||
saldo={pago?.saldo || 0}
|
||||
moneda={pago?.moneda || 'MXN'}
|
||||
onDone={() => load()}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={!!cancelar}
|
||||
onClose={() => setCancelar(null)}
|
||||
title={`Cancelar compra ${cancelar?.folio || ''}`}
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setCancelar(null)}>Volver</Button>
|
||||
<Button variant="danger" onClick={confirmarCancelar} loading={busy}>Cancelar compra</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Solo se pueden cancelar compras <strong>sin recepciones</strong>. Si la mercancía ya
|
||||
entró, registra una baja o un ajuste de inventario.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Input label="Motivo (opcional)" value={cancelMotivo} onChange={(e) => setCancelMotivo(e.target.value)} />
|
||||
</div>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Compras;
|
||||
@@ -19,6 +19,7 @@ import NotificacionesPanel from '../components/NotificacionesPanel';
|
||||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||||
import FormulariosPanel from '../components/FormulariosPanel';
|
||||
import MembresiasPanel from '../components/MembresiasPanel';
|
||||
import ComprasPanel from '../components/ComprasPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
@@ -34,6 +35,7 @@ const TABS = [
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'recursos', label: 'Recursos' },
|
||||
{ key: 'membresias', label: 'Membresías' },
|
||||
{ key: 'compras', label: 'Compras' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
@@ -44,7 +46,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -69,6 +71,7 @@ const Configuracion: FC = () => {
|
||||
notif_enviadas: 'notificaciones.ver',
|
||||
formularios: 'formularios.ver',
|
||||
membresias: 'membresias.editar',
|
||||
compras: 'compras.editar',
|
||||
recursos: 'recursos.editar',
|
||||
};
|
||||
const tabsVisibles = useMemo(
|
||||
@@ -397,6 +400,12 @@ const Configuracion: FC = () => {
|
||||
<MembresiasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'compras' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<ComprasPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'terminologia' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, S
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
|
||||
import { useClinicSettings } from '../lib/clinic-settings';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'items', label: 'Items' },
|
||||
@@ -70,6 +71,9 @@ const Inventario: FC = () => {
|
||||
const tab = searchParams.get('tab') || 'items';
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const impuestos = useClinicSettings().impuestos;
|
||||
const comprasCfg = useClinicSettings().compras;
|
||||
const { can } = useAuth();
|
||||
const puedeEditarCosto = comprasCfg.activo && can('inventario.costos');
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -182,6 +186,7 @@ const Inventario: FC = () => {
|
||||
setEditItem(it);
|
||||
setEditForm({ name: it.name, sku: it.sku, category: it.category, unit: it.unit,
|
||||
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes,
|
||||
costo_promedio: it.costo_promedio ?? 0, ultimo_costo: it.ultimo_costo ?? 0,
|
||||
tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 });
|
||||
};
|
||||
|
||||
@@ -414,7 +419,14 @@ const Inventario: FC = () => {
|
||||
<th className="text-right px-4 py-3">Existencia</th>
|
||||
<th className="text-left px-4 py-3">Nivel</th>
|
||||
<th className="text-right px-4 py-3">Costo</th>
|
||||
{comprasCfg.activo && (
|
||||
<>
|
||||
<th className="text-right px-4 py-3">Costo prom.</th>
|
||||
<th className="text-right px-4 py-3">Último</th>
|
||||
</>
|
||||
)}
|
||||
<th className="text-right px-4 py-3">Precio venta</th>
|
||||
{comprasCfg.activo && <th className="text-right px-4 py-3">Margen</th>}
|
||||
<th className="text-right px-4 py-3">Valor</th>
|
||||
<th className="text-left px-4 py-3">Caducidad</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
@@ -435,7 +447,21 @@ const Inventario: FC = () => {
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
|
||||
{comprasCfg.activo && (
|
||||
<>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.costo_promedio ?? 0)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.ultimo_costo ?? 0)}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading">{fmtMoney(it.price ?? 0)}</td>
|
||||
{comprasCfg.activo && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={(it.margen ?? 0) < 0 ? 'text-rose-600' : 'text-theme-muted'}>
|
||||
{fmtMoney(it.margen ?? 0)}
|
||||
<span className="text-[11px] text-theme-muted"> ({(it.margen_pct ?? 0).toFixed(0)}%)</span>
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.inventory_value)}</td>
|
||||
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -537,6 +563,28 @@ const Inventario: FC = () => {
|
||||
/>
|
||||
)}
|
||||
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(editForm.cost ?? 0)} onChange={(e) => setEditForm({ ...editForm, cost: parseFloat(e.target.value) || 0 })} />
|
||||
{puedeEditarCosto && (
|
||||
<>
|
||||
<Input
|
||||
label="Costo promedio (ajuste manual)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={String(editForm.costo_promedio ?? 0)}
|
||||
onChange={(e) => setEditForm({ ...editForm, costo_promedio: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="Último costo"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={String(editForm.ultimo_costo ?? 0)}
|
||||
onChange={(e) => setEditForm({ ...editForm, ultimo_costo: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
<p className="sm:col-span-2 text-xs text-sky-700 bg-sky-50 rounded-lg p-2">
|
||||
El costo promedio se recalcula solo con cada recepción de compra. Editarlo aquí es
|
||||
un ajuste manual de inventario.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Input label="SKU / Código" value={editForm.sku || ''} onChange={(e) => setEditForm({ ...editForm, sku: e.target.value })} />
|
||||
<Input label="Línea / Categoría" value={editForm.category || ''} onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} />
|
||||
<Input label="Unidad" value={editForm.unit || ''} onChange={(e) => setEditForm({ ...editForm, unit: e.target.value })} />
|
||||
|
||||
@@ -35,6 +35,8 @@ import ConcentradoReport from '../components/reportes/ConcentradoReport';
|
||||
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
|
||||
import KpisReport from '../components/reportes/KpisReport';
|
||||
import RecursosReport from '../components/reportes/RecursosReport';
|
||||
import ComprasReport from '../components/reportes/ComprasReport';
|
||||
import MargenReport from '../components/reportes/MargenReport';
|
||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { CommissionRow } from '../services/odoo';
|
||||
@@ -104,6 +106,8 @@ const FAMILIAS: ReportFamily[] = [
|
||||
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
|
||||
{ key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' },
|
||||
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
|
||||
{ key: 'compras', label: 'Compras', desc: 'Gasto por periodo, proveedor y artículo' },
|
||||
{ key: 'margen', label: 'Margen', desc: 'Margen por artículo: precio menos costo promedio' },
|
||||
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
|
||||
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
|
||||
],
|
||||
@@ -150,7 +154,7 @@ interface CashReport {
|
||||
const ReportesContent: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { hasRole, canSee, can, brand } = useAuth();
|
||||
const { membresias: membresiasCfg, recursos: recursosCfg } = useClinicSettings();
|
||||
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
|
||||
const esAdmin = hasRole('admin');
|
||||
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
|
||||
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
|
||||
@@ -160,8 +164,9 @@ const ReportesContent: FC = () => {
|
||||
const puedeTab = useCallback((k: string) => {
|
||||
if (k === 'membresias' && !membresiasCfg.activo) return false;
|
||||
if (k === 'recursos' && !recursosCfg.activo) return false;
|
||||
if ((k === 'compras' || k === 'margen') && !comprasCfg.activo) return false;
|
||||
return canSee(`/reportes?tab=${k}`);
|
||||
}, [canSee, membresiasCfg.activo, recursosCfg.activo]);
|
||||
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo]);
|
||||
const familiasVisibles = useMemo(
|
||||
() =>
|
||||
FAMILIAS
|
||||
@@ -465,6 +470,10 @@ const ReportesContent: FC = () => {
|
||||
<ImpuestosReport />
|
||||
) : tab === 'inventario' ? (
|
||||
<InventarioReport />
|
||||
) : tab === 'compras' ? (
|
||||
<ComprasReport />
|
||||
) : tab === 'margen' ? (
|
||||
<MargenReport />
|
||||
) : tab === 'adeudos' ? (
|
||||
<AdeudosReport />
|
||||
) : tab === 'comisiones' ? (
|
||||
|
||||
@@ -816,6 +816,11 @@ export interface InventoryItem {
|
||||
qty_min: number;
|
||||
cost: number;
|
||||
price?: number;
|
||||
/** Costo promedio ponderado de compras y último costo recibido (módulo Compras). */
|
||||
costo_promedio?: number;
|
||||
ultimo_costo?: number;
|
||||
margen?: number;
|
||||
margen_pct?: number;
|
||||
inventory_value: number;
|
||||
expiry_date: string | null;
|
||||
last_count_date: string | null;
|
||||
@@ -1069,6 +1074,7 @@ export interface ClinicSettings {
|
||||
notificaciones: NotificacionesSettings;
|
||||
membresias: MembresiasSettings;
|
||||
recursos: RecursosSettings;
|
||||
compras: ComprasSettings;
|
||||
}
|
||||
|
||||
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||
@@ -1234,6 +1240,238 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
||||
dias_gracia: 5,
|
||||
};
|
||||
|
||||
// ---- Compras y proveedores ----
|
||||
export interface ComprasSettings {
|
||||
/** Enciende TODO el módulo: menú, recepciones, costo promedio y reportes. */
|
||||
activo: boolean;
|
||||
/** Al recibir, recalcula el costo promedio ponderado del artículo. */
|
||||
actualizar_costo_promedio: boolean;
|
||||
/** Permite recibir menos de lo pedido y completar en recepciones posteriores. */
|
||||
permitir_recepcion_parcial: boolean;
|
||||
/** Exige enviar (autorizar) la orden antes de poder recibirla. */
|
||||
requiere_autorizacion: boolean;
|
||||
/** Días de crédito por defecto de un proveedor nuevo. */
|
||||
dias_credito_default: number;
|
||||
/** Margen (%) bajo el cual el reporte de margen avisa. */
|
||||
margen_minimo_alerta: number;
|
||||
/** Permite registrar una recepción sin orden de compra. */
|
||||
permitir_compras_sin_orden: boolean;
|
||||
}
|
||||
|
||||
export const COMPRAS_DEFAULT: ComprasSettings = {
|
||||
activo: false,
|
||||
actualizar_costo_promedio: true,
|
||||
permitir_recepcion_parcial: true,
|
||||
requiere_autorizacion: false,
|
||||
dias_credito_default: 0,
|
||||
margen_minimo_alerta: 0,
|
||||
permitir_compras_sin_orden: false,
|
||||
};
|
||||
|
||||
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
|
||||
export type MonedaCompra = 'MXN' | 'USD';
|
||||
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
|
||||
|
||||
export interface Proveedor {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
vat: string;
|
||||
street: string;
|
||||
city: string;
|
||||
supplier_rank: number;
|
||||
activo: boolean;
|
||||
dias_credito: number;
|
||||
contacto: string;
|
||||
banco: string;
|
||||
cuenta_pago: string;
|
||||
notas: string;
|
||||
compras_count: number;
|
||||
saldo: number;
|
||||
}
|
||||
|
||||
export interface CompraLinea {
|
||||
id: number;
|
||||
item_id: number;
|
||||
item: string;
|
||||
sku: string;
|
||||
unit: string;
|
||||
kind: 'producto' | 'consumible';
|
||||
cantidad_pedida: number;
|
||||
cantidad_recibida: number;
|
||||
cantidad_pendiente: number;
|
||||
costo_unitario: number;
|
||||
descuento: number;
|
||||
subtotal: number;
|
||||
impuesto: number;
|
||||
total: number;
|
||||
lote: string;
|
||||
fecha_caducidad: string | null;
|
||||
notas: string;
|
||||
}
|
||||
|
||||
export interface CompraRecepcionLinea {
|
||||
id: number;
|
||||
item_id: number;
|
||||
item: string;
|
||||
cantidad: number;
|
||||
costo_unitario: number;
|
||||
lote: string;
|
||||
fecha_caducidad: string | null;
|
||||
move_id: number | null;
|
||||
}
|
||||
|
||||
export interface CompraRecepcion {
|
||||
id: number;
|
||||
fecha: string | null;
|
||||
usuario: string;
|
||||
notas: string;
|
||||
total_unidades: number;
|
||||
total_costo: number;
|
||||
lineas: CompraRecepcionLinea[];
|
||||
}
|
||||
|
||||
export interface CompraPago {
|
||||
id: number;
|
||||
fecha: string | null;
|
||||
monto: number;
|
||||
moneda: MonedaCompra;
|
||||
metodo: MetodoPagoCompra;
|
||||
metodo_label: string;
|
||||
referencia: string;
|
||||
notas: string;
|
||||
usuario: string;
|
||||
monto_en_compra: number;
|
||||
}
|
||||
|
||||
export interface Compra {
|
||||
id: number;
|
||||
folio: string;
|
||||
partner_id: number;
|
||||
proveedor: string;
|
||||
fecha: string | null;
|
||||
fecha_entrega_esperada: string | null;
|
||||
estado: EstadoCompra;
|
||||
estado_label: string;
|
||||
moneda: MonedaCompra;
|
||||
subtotal: number;
|
||||
descuento: number;
|
||||
impuestos: number;
|
||||
total: number;
|
||||
pagado: number;
|
||||
saldo: number;
|
||||
sucursal_id: number | null;
|
||||
sucursal: string;
|
||||
referencia_factura: string;
|
||||
notas: string;
|
||||
creado_por_login: string;
|
||||
recibida_at: string | null;
|
||||
cancel_reason: string;
|
||||
lineas_count: number;
|
||||
recepciones_count: number;
|
||||
tiene_recepciones: boolean;
|
||||
total_pedido: number;
|
||||
total_recibido: number;
|
||||
puede_recibir: boolean;
|
||||
puede_cancelar: boolean;
|
||||
lineas?: CompraLinea[];
|
||||
recepciones?: CompraRecepcion[];
|
||||
pagos?: CompraPago[];
|
||||
}
|
||||
|
||||
export interface CompraInput {
|
||||
partner_id?: number;
|
||||
fecha?: string;
|
||||
fecha_entrega_esperada?: string | null;
|
||||
moneda?: MonedaCompra;
|
||||
descuento?: number;
|
||||
notas?: string;
|
||||
referencia_factura?: string;
|
||||
sucursal_id?: number | null;
|
||||
lineas?: {
|
||||
item_id: number;
|
||||
cantidad_pedida: number;
|
||||
costo_unitario?: number;
|
||||
descuento?: number;
|
||||
lote?: string;
|
||||
fecha_caducidad?: string | null;
|
||||
notas?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CompraResumen {
|
||||
abiertas: number;
|
||||
por_pagar: number;
|
||||
saldo_total: number;
|
||||
compras_mes: number;
|
||||
gasto_mes: number;
|
||||
borradores: number;
|
||||
enviadas: number;
|
||||
}
|
||||
|
||||
export interface CuentaPorPagar {
|
||||
partner_id: number;
|
||||
proveedor: string;
|
||||
dias_credito: number;
|
||||
saldo: number;
|
||||
compras: number;
|
||||
buckets: Record<string, number>;
|
||||
detalle: {
|
||||
id: number; folio: string; fecha: string | null; fecha_vencimiento: string | null;
|
||||
dias: number; bucket: string; total: number; pagado: number; saldo: number;
|
||||
moneda: MonedaCompra;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CuentasPorPagarResponse {
|
||||
status: string;
|
||||
proveedores: CuentaPorPagar[];
|
||||
buckets: { key: string; label: string }[];
|
||||
totales: { saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number };
|
||||
}
|
||||
|
||||
export interface ComprasReportResponse {
|
||||
status: string;
|
||||
start: string;
|
||||
end: string;
|
||||
totales: { compras: number; subtotal: number; impuestos: number; total: number; pagado: number; saldo: number };
|
||||
por_proveedor: { partner_id: number; proveedor: string; compras: number; total: number; pagado: number; saldo: number }[];
|
||||
por_articulo: { item_id: number; item: string; sku: string; category: string; unidad: string;
|
||||
cantidad_pedida: number; cantidad_recibida: number; subtotal: number }[];
|
||||
por_mes: { mes: string; total: number }[];
|
||||
cuentas_por_pagar: { total: number; proveedores: { partner_id: number; proveedor: string; saldo: number; compras: number; dias_credito: number }[] };
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MargenReportFila {
|
||||
item_id: number;
|
||||
item: string;
|
||||
sku: string;
|
||||
category: string;
|
||||
unidad: string;
|
||||
precio: number;
|
||||
costo_promedio: number;
|
||||
margen_unitario: number;
|
||||
margen_pct: number;
|
||||
unidades_vendidas: number;
|
||||
ingresos: number;
|
||||
costo_ventas: number;
|
||||
margen_total: number;
|
||||
alerta: boolean;
|
||||
}
|
||||
|
||||
export interface MargenReportResponse {
|
||||
status: string;
|
||||
start: string;
|
||||
end: string;
|
||||
margen_minimo_alerta: number;
|
||||
articulos: MargenReportFila[];
|
||||
totales: { ingresos: number; costo_ventas: number; margen: number; margen_pct: number; alertas: number };
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
|
||||
export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
|
||||
export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
|
||||
@@ -1636,6 +1874,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
notificaciones: NOTIFICACIONES_DEFAULT,
|
||||
membresias: MEMBRESIAS_DEFAULT,
|
||||
recursos: RECURSOS_DEFAULT,
|
||||
compras: COMPRAS_DEFAULT,
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
@@ -2330,6 +2569,96 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Compras y proveedores ----
|
||||
// Con `compras.activo=false` (default) el backend responde 403 y estas
|
||||
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
||||
async getProveedores(opts?: { includeInactive?: boolean; q?: string; page?: number; page_size?: number }): Promise<{
|
||||
status: string; proveedores: Proveedor[]; total: number; page: number; page_size: number; message?: string;
|
||||
}> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, {
|
||||
params: {
|
||||
...(opts?.includeInactive ? { include_inactive: 1 } : {}),
|
||||
...(opts?.q ? { q: opts.q } : {}),
|
||||
...(opts?.page ? { page: opts.page } : {}),
|
||||
...(opts?.page_size ? { page_size: opts.page_size } : {}),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async createProveedor(payload: Partial<Proveedor> & { name: string }): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateProveedor(id: number, payload: Partial<Proveedor>): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteProveedor(id: number): Promise<{ status: string; proveedor?: Proveedor; message?: string; desactivar?: boolean }> {
|
||||
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getCompras(params?: { estado?: string; partner_id?: number; start?: string; end?: string; q?: string; page?: number; page_size?: number }): Promise<{
|
||||
status: string; compras: Compra[]; total: number; page: number; page_size: number; total_pages: number;
|
||||
resumen: CompraResumen; message?: string;
|
||||
}> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async crearOrdenCompra(payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateCompra(id: number, payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async enviarCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/enviar`, {});
|
||||
return data;
|
||||
},
|
||||
|
||||
async recibirCompra(id: number, payload: { notas?: string; lineas: { linea_id: number; cantidad: number; lote?: string; fecha_caducidad?: string | null; costo_unitario?: number }[] }): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/recibir`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancelarCompra(id: number, motivo?: string): Promise<{ status: string; compra: Compra; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/cancelar`, { motivo });
|
||||
return data;
|
||||
},
|
||||
|
||||
async pagarCompra(id: number, payload: { monto: number; moneda?: MonedaCompra; metodo?: MetodoPagoCompra; referencia?: string; notas?: string }): Promise<{ status: string; compra: Compra; pago: CompraPago; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/pagar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getCuentasPorPagar(params?: { partner_id?: number }): Promise<CuentasPorPagarResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/cuentas_por_pagar`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getComprasReport(start: string, end: string): Promise<ComprasReportResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/compras`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getMargenReport(start: string, end: string): Promise<MargenReportResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/margen`, { params: { start, end } });
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Membresías y suscripciones ----
|
||||
// Con `membresias.activo=false` (default) el backend responde 403 y estas
|
||||
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
||||
|
||||
Reference in New Issue
Block a user