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:
2026-10-11 12:17:23 +00:00
parent ee8a844d96
commit a916753ebe
38 changed files with 4201 additions and 30 deletions

View 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;