POS: venta de artículos de inventario (productos/consumibles)

- Toggle Servicios|Artículos en el catálogo del POS
- Líneas de venta con item_id (service_id opcional), stock descargado
  con movimiento 'venta' al cobrar, advertencias de sobreventa
- Campo precio de venta en artículos de inventario (backend Odoo)
This commit is contained in:
2026-08-14 01:52:01 +00:00
parent 07e05a643e
commit 84f34c542f
6 changed files with 193 additions and 36 deletions

View File

@@ -2,7 +2,7 @@ import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2,
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, Package,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
@@ -15,7 +15,7 @@ import {
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type PosCheckoutResult } from '../services/odoo';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult } from '../services/odoo';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
@@ -30,18 +30,25 @@ const METODO_OPTIONS = [
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
interface TicketLine {
service: Service;
key: string;
name: string;
qty: number;
price: number;
service_id?: number;
item_id?: number;
stock?: number;
unit?: string;
}
const Pos: FC = () => {
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
const [services, setServices] = useState<Service[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
const [categoria, setCategoria] = useState('');
const [soloFavoritos, setSoloFavoritos] = useState(false);
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
// Ticket
const [lines, setLines] = useState<TicketLine[]>([]);
@@ -82,10 +89,26 @@ const Pos: FC = () => {
}
}, [search, categoria]);
const loadItems = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getInventarioItems(search || undefined);
if (res.status === 'success') setInvItems(res.items);
} catch (err) {
toast.error('Error al cargar artículos');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, [search]);
useEffect(() => {
const t = setTimeout(loadServices, 300);
const t = setTimeout(() => {
if (catMode === 'servicios') loadServices();
else loadItems();
}, 300);
return () => clearTimeout(t);
}, [loadServices]);
}, [loadServices, loadItems, catMode]);
useEffect(() => {
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
@@ -121,20 +144,33 @@ const Pos: FC = () => {
[services, soloFavoritos]
);
// Los artículos ya vienen filtrados del server-side (search)
const visiblesItems = invItems;
// ---- Ticket ----
const addLine = (service: Service) => {
setLines((prev) => {
const found = prev.find((l) => l.service.id === service.id);
if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { service, qty: 1, price: service.price }];
const key = `s-${service.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id }];
});
};
const updateLine = (id: number, patch: Partial<TicketLine>) => {
setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l)));
const addItemLine = (item: InventoryItem) => {
setLines((prev) => {
const key = `i-${item.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit }];
});
};
const removeLine = (id: number) => setLines((prev) => prev.filter((l) => l.service.id !== id));
const updateLine = (key: string, patch: Partial<TicketLine>) => {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
};
const removeLine = (key: string) => setLines((prev) => prev.filter((l) => l.key !== key));
const limpiarTicket = () => {
if (lines.length === 0) return;
@@ -189,7 +225,12 @@ const Pos: FC = () => {
setCobrando(true);
const res = await odooApi.posCheckout({
partner_id: paciente.id,
lines: lines.map((l) => ({ service_id: l.service.id, quantity: l.qty, price_unit: l.price })),
lines: lines.map((l) => ({
...(l.service_id ? { service_id: l.service_id } : {}),
...(l.item_id ? { item_id: l.item_id } : {}),
quantity: l.qty,
price_unit: l.price,
})),
discount: discountNum,
payment_method: metodo,
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
@@ -199,6 +240,9 @@ const Pos: FC = () => {
setResultado(res);
setCobroOpen(false);
toast.success(`Venta ${res.sale.name} cobrada`);
if (res.stock_warnings && res.stock_warnings.length > 0) {
res.stock_warnings.forEach((w) => toast.error(`Stock: ${w}`));
}
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
@@ -288,29 +332,33 @@ const Pos: FC = () => {
) : (
<div className="divide-y divide-theme-border">
{lines.map((l) => (
<div key={l.service.id} className="flex items-center gap-1.5 py-2">
<div key={l.key} className="flex items-center gap-1.5 py-2">
<button
type="button"
onClick={() => removeLine(l.service.id)}
onClick={() => removeLine(l.key)}
className="text-theme-muted hover:text-rose-600 shrink-0"
title="Quitar"
>
<X size={14} />
</button>
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.service.name}>
{l.service.name}
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
)}
</p>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: l.qty + 1 })}><Plus size={12} /></Button>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: l.qty + 1 })}><Plus size={12} /></Button>
</div>
<input
type="number"
min={0}
step={50}
value={l.price}
onChange={(e) => updateLine(l.service.id, { price: parseFloat(e.target.value) || 0 })}
onChange={(e) => updateLine(l.key, { price: parseFloat(e.target.value) || 0 })}
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
title="Precio unitario"
/>
@@ -372,12 +420,34 @@ const Pos: FC = () => {
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar servicio..."
placeholder={catMode === 'servicios' ? 'Buscar servicio...' : 'Buscar artículo...'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Toggle Servicios / Artículos */}
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
<button
type="button"
onClick={() => setCatMode('servicios')}
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
catMode === 'servicios' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
<Store size={14} /> Servicios
</button>
<button
type="button"
onClick={() => setCatMode('articulos')}
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
catMode === 'articulos' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
<Package size={14} /> Artículos
</button>
</div>
{catMode === 'servicios' && (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
type="button"
@@ -410,9 +480,33 @@ const Pos: FC = () => {
</button>
))}
</div>
)}
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : catMode === 'articulos' ? (
visiblesItems.length === 0 ? (
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{visiblesItems.map((i) => (
<button
key={i.id}
type="button"
onClick={() => addItemLine(i)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={i.name}>
{i.name}
</p>
<div className="flex items-center justify-between mt-1">
<span className={`text-xs ${i.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>{i.qty} {i.unit || 'pzas'}</span>
<span className="text-sm font-bold text-theme-heading">{(i.price ?? 0) > 0 ? fmtMoney(i.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}</span>
</div>
</button>
))}
</div>
)
) : visibles.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
) : (
@@ -566,7 +660,7 @@ const Pos: FC = () => {
<tbody>
{resultado.sale.lines.map((l) => (
<tr key={l.id}>
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.description}</td>
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.item || l.description}</td>
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
</tr>
))}

View File

@@ -285,6 +285,8 @@ export interface SaleLine {
id?: number;
service_id: number;
service?: string;
item_id?: number | null;
item?: string;
description: string;
quantity: number;
price_unit: number;
@@ -376,6 +378,7 @@ export interface InventoryItem {
qty_optimal: number;
qty_min: number;
cost: number;
price?: number;
inventory_value: number;
expiry_date: string | null;
last_count_date: string | null;
@@ -682,7 +685,7 @@ export interface AppNotification {
export interface PosCheckoutPayload {
partner_id: number;
lines: { service_id: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
lines: { service_id?: number; item_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
discount?: number;
payment_method?: string;
amount_received?: number;
@@ -697,6 +700,7 @@ export interface PosCheckoutResult {
puntos_usados: number;
puntos_ganados: number;
wallet_points: number;
stock_warnings?: string[];
}
export interface DailyReport {