Inventario: campo de precio de venta editable (faltaba en la UI)

El modelo tenia el campo price y el POS lo usa para cobrar, pero ni el
formulario de alta lo capturaba ni existia modal de edicion: los 170
articulos tenian precio en 0 y recepcion tecleaba el precio a mano en
cada venta.

- Backend: inventory_create acepta price; inventory_update lo permite
  entre los campos editables (con conversion float).
- Frontend: columna 'Precio venta' en la tabla, campo en el alta y
  modal nuevo 'Editar articulo' (nombre, precio, costo, SKU, categoria,
  unidad, niveles y notas). La existencia sigue cambiando solo via
  Ajustar para dejar registro del movimiento.
This commit is contained in:
2026-09-29 23:33:12 +00:00
parent 7b54e40104
commit 5a16f694de
2 changed files with 69 additions and 6 deletions

View File

@@ -1,13 +1,13 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet, FlaskConical, ClipboardCheck, Printer } from 'lucide-react'; import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet, FlaskConical, ClipboardCheck, Printer, Pencil } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import MovimientosPanel from '../components/inventario/MovimientosPanel'; import MovimientosPanel from '../components/inventario/MovimientosPanel';
import AlertasPanel from '../components/inventario/AlertasPanel'; import AlertasPanel from '../components/inventario/AlertasPanel';
import ConteoFisicoModal from '../components/inventario/ConteoFisicoModal'; import ConteoFisicoModal from '../components/inventario/ConteoFisicoModal';
import ConteoPrint from '../components/inventario/ConteoPrint'; import ConteoPrint from '../components/inventario/ConteoPrint';
import { Card, Button, Input, Select, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui'; import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui';
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo'; import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
@@ -60,7 +60,7 @@ const MOVE_OPTIONS = [
const emptyItem: Partial<InventoryItem> = { const emptyItem: Partial<InventoryItem> = {
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza', name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, expiry_date: null, notes: '', qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, price: 0, expiry_date: null, notes: '',
}; };
const Inventario: FC = () => { const Inventario: FC = () => {
@@ -87,6 +87,11 @@ const Inventario: FC = () => {
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null); const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
const [moves, setMoves] = useState<InventoryMove[]>([]); const [moves, setMoves] = useState<InventoryMove[]>([]);
// Edición del artículo (nombre, precios, niveles, etc.)
const [editItem, setEditItem] = useState<InventoryItem | null>(null);
const [editForm, setEditForm] = useState<Partial<InventoryItem>>({});
const [savingEdit, setSavingEdit] = useState(false);
// Conteo físico cíclico // Conteo físico cíclico
const [conteoOpen, setConteoOpen] = useState(false); const [conteoOpen, setConteoOpen] = useState(false);
const [conteoPrint, setConteoPrint] = useState<InventoryConteo | null>(null); const [conteoPrint, setConteoPrint] = useState<InventoryConteo | null>(null);
@@ -170,6 +175,28 @@ const Inventario: FC = () => {
setMoveRef(''); setMoveRef('');
}; };
const openEdit = (it: InventoryItem) => {
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 });
};
const saveEdit = async () => {
if (!editItem) return;
if (!editForm.name || !(editForm.name || '').trim()) { toast.error('El nombre es obligatorio'); return; }
setSavingEdit(true);
try {
await odooApi.updateInventoryItem(editItem.id, editForm);
toast.success('Artículo actualizado');
setEditItem(null);
load();
} catch {
toast.error('Error al actualizar el artículo');
} finally {
setSavingEdit(false);
}
};
const submitMove = async () => { const submitMove = async () => {
if (!adjustItem) return; if (!adjustItem) return;
const q = parseFloat(moveQty); const q = parseFloat(moveQty);
@@ -383,6 +410,7 @@ const Inventario: FC = () => {
<th className="text-right px-4 py-3">Existencia</th> <th className="text-right px-4 py-3">Existencia</th>
<th className="text-left px-4 py-3">Nivel</th> <th className="text-left px-4 py-3">Nivel</th>
<th className="text-right px-4 py-3">Costo</th> <th className="text-right px-4 py-3">Costo</th>
<th className="text-right px-4 py-3">Precio venta</th>
<th className="text-right px-4 py-3">Valor</th> <th className="text-right px-4 py-3">Valor</th>
<th className="text-left px-4 py-3">Caducidad</th> <th className="text-left px-4 py-3">Caducidad</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3"></th>
@@ -403,10 +431,19 @@ 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 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"><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> <td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(it.inventory_value)}</td> <td className="px-4 py-3 text-right font-medium text-theme-heading">{fmtMoney(it.price ?? 0)}</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 text-theme-muted">{it.expiry_date || '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(it)}
title="Editar nombre, precios y datos"
>
<Pencil size={14} />
</Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -444,6 +481,7 @@ const Inventario: FC = () => {
<Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} /> <Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} />
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} /> <Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} /> <Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(newForm.price ?? 0)} onChange={(e) => setNewForm({ ...newForm, price: parseFloat(e.target.value) || 0 })} />
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} /> <Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} /> <Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
{newForm.kind === 'consumible' && ( {newForm.kind === 'consumible' && (
@@ -452,6 +490,30 @@ const Inventario: FC = () => {
</div> </div>
</Modal> </Modal>
{/* Modal editar artículo */}
<Modal isOpen={!!editItem} onClose={() => setEditItem(null)} title={editItem ? `Editar: ${editItem.name}` : 'Editar'} maxWidth="md"
footer={<>
<Button variant="ghost" onClick={() => setEditItem(null)}>Cancelar</Button>
<Button onClick={saveEdit} loading={savingEdit}>Guardar</Button>
</>}>
{editItem && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"><Input label="Nombre" value={editForm.name || ''} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(editForm.price ?? 0)} onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })} />
<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 })} />
<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 })} />
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(editForm.qty_min ?? 0)} onChange={(e) => setEditForm({ ...editForm, qty_min: parseFloat(e.target.value) || 0 })} />
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(editForm.qty_optimal ?? 0)} onChange={(e) => setEditForm({ ...editForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
<div className="sm:col-span-2">
<TextArea label="Notas" value={editForm.notes || ''} onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })} />
</div>
<p className="sm:col-span-2 text-xs text-theme-muted">La existencia se modifica con el botón "Ajustar" para dejar registro del movimiento.</p>
</div>
)}
</Modal>
{/* Modal ajustar / movimiento */} {/* Modal ajustar / movimiento */}
<Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md" <Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
footer={<> footer={<>

View File

@@ -6426,6 +6426,7 @@ class SkeenFrontendController(http.Controller):
'qty_optimal': float(data.get('qty_optimal', 0) or 0), 'qty_optimal': float(data.get('qty_optimal', 0) or 0),
'qty_min': float(data.get('qty_min', 0) or 0), 'qty_min': float(data.get('qty_min', 0) or 0),
'cost': float(data.get('cost', 0) or 0), 'cost': float(data.get('cost', 0) or 0),
'price': float(data.get('price', 0) or 0),
'expiry_date': data.get('expiry_date') or False, 'expiry_date': data.get('expiry_date') or False,
'notes': data.get('notes', ''), 'notes': data.get('notes', ''),
} }
@@ -6444,12 +6445,12 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404) return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body() data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min', allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'expiry_date', 'notes', 'active', 'genera_monedero'] 'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero']
vals = {} vals = {}
for k in allowed: for k in allowed:
if k in data: if k in data:
vals[k] = data[k] vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost'): for f in ('qty_optimal', 'qty_min', 'cost', 'price'):
if f in vals: if f in vals:
vals[f] = float(vals[f] or 0) vals[f] = float(vals[f] or 0)
item.write(vals) item.write(vals)