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:
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;
|
||||
Reference in New Issue
Block a user