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:
@@ -1,13 +1,13 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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 MovimientosPanel from '../components/inventario/MovimientosPanel';
|
||||
import AlertasPanel from '../components/inventario/AlertasPanel';
|
||||
import ConteoFisicoModal from '../components/inventario/ConteoFisicoModal';
|
||||
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 type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
|
||||
|
||||
@@ -60,7 +60,7 @@ const MOVE_OPTIONS = [
|
||||
|
||||
const emptyItem: Partial<InventoryItem> = {
|
||||
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 = () => {
|
||||
@@ -87,6 +87,11 @@ const Inventario: FC = () => {
|
||||
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
|
||||
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
|
||||
const [conteoOpen, setConteoOpen] = useState(false);
|
||||
const [conteoPrint, setConteoPrint] = useState<InventoryConteo | null>(null);
|
||||
@@ -170,6 +175,28 @@ const Inventario: FC = () => {
|
||||
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 () => {
|
||||
if (!adjustItem) return;
|
||||
const q = parseFloat(moveQty);
|
||||
@@ -383,6 +410,7 @@ 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>
|
||||
<th className="text-right px-4 py-3">Precio venta</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>
|
||||
@@ -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"><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-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">
|
||||
<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
|
||||
size="sm"
|
||||
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="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="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="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' && (
|
||||
@@ -452,6 +490,30 @@ const Inventario: FC = () => {
|
||||
</div>
|
||||
</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 isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
|
||||
footer={<>
|
||||
|
||||
@@ -6426,6 +6426,7 @@ class SkeenFrontendController(http.Controller):
|
||||
'qty_optimal': float(data.get('qty_optimal', 0) or 0),
|
||||
'qty_min': float(data.get('qty_min', 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,
|
||||
'notes': data.get('notes', ''),
|
||||
}
|
||||
@@ -6444,12 +6445,12 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
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 = {}
|
||||
for k in allowed:
|
||||
if k in data:
|
||||
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:
|
||||
vals[f] = float(vals[f] or 0)
|
||||
item.write(vals)
|
||||
|
||||
Reference in New Issue
Block a user