Fix: Accesos Directos a Productos Desde Caja Administrables

This commit is contained in:
2026-02-27 19:44:52 -03:00
parent b4fa74ad9b
commit 284ec7add6
14 changed files with 600 additions and 41 deletions

View File

@@ -8,6 +8,9 @@
"name": "counter-panel",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
@@ -333,6 +336,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",

View File

@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",

View File

@@ -0,0 +1,93 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Trash2, Layers } from 'lucide-react';
import { motion } from 'framer-motion';
import type { Product } from '../../types/Product';
interface Props {
id: number;
product: Product;
onSelect: (product: Product) => void;
onRemove: (productId: number, e: React.MouseEvent) => void;
}
export function DraggableShortcutCard({ id, product, onSelect, onRemove }: Props) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : 'auto',
opacity: isDragging ? 0.8 : 1,
scale: isDragging ? 1.05 : 1,
};
const getShortcutStyles = (typeCode?: string) => {
switch (typeCode) {
case 'BUNDLE': return 'from-purple-50 to-white border-purple-100 hover:border-purple-400';
case 'CLASSIFIED_AD': return 'from-blue-50 to-white border-blue-100 hover:border-blue-400';
default: return 'from-slate-50 to-white border-slate-100 hover:border-slate-400';
}
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="relative group cursor-grab active:cursor-grabbing h-32"
>
<motion.button
layout
onClick={() => onSelect(product)}
className={`w-full h-full p-4 rounded-2xl bg-gradient-to-br shadow-sm border-2 transition-all text-left flex flex-col justify-between overflow-hidden relative ${getShortcutStyles(product.typeCode)}`}
>
<button
onClick={(e) => onRemove(product.id, e)}
className="absolute top-2 right-2 p-1.5 bg-white/80 backdrop-blur-sm text-red-500 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 hover:bg-red-500 hover:text-white shadow-sm border border-red-100"
>
<Trash2 size={12} />
</button>
<div className="flex flex-col gap-0.5">
<div className="flex justify-between items-start">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 truncate pr-6">
{product.companyName || 'Empresa General'}
</span>
{product.typeCode === 'BUNDLE' && <Layers size={14} className="text-purple-400 shrink-0" />}
</div>
<div className="text-sm font-black text-slate-800 line-clamp-2 leading-tight mt-1 group-hover:text-blue-700 transition-colors">
{product.name}
</div>
</div>
<div className="flex items-end justify-between mt-auto">
<div className="flex flex-col">
<div className={`text-[9px] font-bold uppercase tracking-tighter ${product.typeCode === 'BUNDLE' ? 'text-purple-500' :
product.typeCode === 'CLASSIFIED_AD' ? 'text-blue-500' : 'text-slate-500'
}`}>
{product.typeCode === 'BUNDLE' ? 'Combo' :
product.typeCode === 'CLASSIFIED_AD' ? 'Aviso' : 'Producto'}
</div>
{product.sku && <div className="text-[8px] font-mono text-slate-300">#{product.sku}</div>}
</div>
<div className="text-lg font-mono font-black text-slate-900 leading-none">
<span className="text-xs font-bold mr-0.5 opacity-50">$</span>
{product.basePrice.toLocaleString()}
</div>
</div>
{/* Sutil brillo al hover */}
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 pointer-events-none" />
</motion.button>
</div>
);
}

View File

@@ -51,6 +51,7 @@ export default function ProductSearch({ products, onSelect }: Props) {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
id="pos-search-input"
type="text"
className="w-full pl-10 pr-4 py-3 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-500/10 font-bold text-sm transition-all"
placeholder="Buscar producto por nombre o SKU (F3)..."

View File

@@ -0,0 +1,99 @@
import { useState } from 'react';
import { X, Search, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
import type { Product } from '../../types/Product';
interface Props {
catalog: Product[];
onClose: () => void;
onAdd: (productId: number) => void;
existingIds: number[];
}
export default function ShortcutAddModal({ catalog, onClose, onAdd, existingIds }: Props) {
const [searchTerm, setSearchTerm] = useState('');
// Filtrar productos que no estén ya anclados
const filtered = catalog.filter(p =>
!existingIds.includes(p.id) &&
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const getProductTypeLabel = (typeCode?: string) => {
switch (typeCode) {
case 'BUNDLE': return 'Combo / Paquete';
case 'CLASSIFIED_AD': return 'Aviso Clasificado';
case 'PHYSICAL': return 'Producto Físico';
case 'SERVICE': return 'Servicio';
case 'GRAPHIC': return 'Publicidad Gráfica';
case 'RADIO': return 'Publicidad Radial';
default: return 'Producto';
}
};
return (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[250] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] border border-slate-200"
>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50">
<div>
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight">Anclar Producto</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Añadir a accesos rápidos</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl text-slate-400 transition-colors">
<X size={20} />
</button>
</div>
<div className="p-8 space-y-6 flex-1 flex flex-col min-h-0">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
autoFocus
className="w-full pl-12 pr-4 py-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 font-bold text-sm transition-all"
placeholder="Buscar producto o combo..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-2 pr-2">
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p className="text-sm font-bold italic">No se encontraron productos disponibles</p>
</div>
) : (
filtered.map(p => (
<button
key={p.id}
onClick={() => onAdd(p.id)}
className="w-full p-4 rounded-2xl border border-slate-100 hover:border-blue-400 hover:bg-blue-50/30 transition-all flex justify-between items-center group"
>
<div className="text-left">
<div className="text-sm font-black text-slate-700 group-hover:text-blue-700">{p.name}</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">
{getProductTypeLabel(p.typeCode)}
</div>
</div>
<div className="bg-white p-2 rounded-xl text-blue-500 shadow-sm group-hover:bg-blue-600 group-hover:text-white transition-all">
<Plus size={16} />
</div>
</button>
))
)}
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 text-center">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Selecciona un producto para anclarlo a tu grilla
</p>
</div>
</motion.div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useCartStore } from '../store/cartStore';
import { productService } from '../services/productService';
import type { Product } from '../types/Product';
import ProductSearch from '../components/POS/ProductSearch';
import { Trash2, ShoppingCart, CreditCard, User, Box, Layers } from 'lucide-react';
import { Trash2, ShoppingCart, CreditCard, User, Box, Layers, PlusCircle } from 'lucide-react';
import { useToast } from '../context/use-toast';
import PaymentModal, { type Payment } from '../components/PaymentModal';
import { orderService } from '../services/orderService';
@@ -12,13 +12,35 @@ import AdEditorModal from '../components/POS/AdEditorModal';
import BundleConfiguratorModal, { type ComponentConfig } from '../components/POS/BundleConfiguratorModal';
import ClientCreateModal from '../components/POS/ClientCreateModal';
import ClientSearchModal from '../components/POS/ClientSearchModal';
import ShortcutAddModal from '../components/POS/ShortcutAddModal';
import { DraggableShortcutCard } from '../components/POS/DraggableShortcutCard';
import { AnimatePresence } from 'framer-motion';
// DND Kit
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
export default function UniversalPosPage() {
const { showToast } = useToast();
const { items, addItem, removeItem, clearCart, getTotal, clientId, clientName, setClient, sellerId, setSeller } = useCartStore();
const [catalog, setCatalog] = useState<Product[]>([]);
const [shortcuts, setShortcuts] = useState<any[]>([]);
const [loadingShortcuts, setLoadingShortcuts] = useState(true);
const [showShortcutAdd, setShowShortcutAdd] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [showPayment, setShowPayment] = useState(false);
@@ -33,8 +55,31 @@ export default function UniversalPosPage() {
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
const [addingProduct, setAddingProduct] = useState(false);
const fetchShortcuts = async () => {
setLoadingShortcuts(true);
try {
const data = await productService.getShortcuts();
setShortcuts(data || []);
} catch (e) {
console.error(e);
setShortcuts([]);
} finally {
setLoadingShortcuts(false);
}
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // Evita disparar drag al simplemente hacer click
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
productService.getAll().then(setCatalog).catch(console.error);
fetchShortcuts();
const userStr = localStorage.getItem('user');
if (userStr) {
try {
@@ -52,11 +97,23 @@ export default function UniversalPosPage() {
e.preventDefault();
handleCheckout();
}
// F7: Cambiar Cliente (Antes F9)
// F7: Cambiar Cliente
if (e.key === 'F7') {
e.preventDefault();
handleChangeClient();
}
// F3: Buscar Producto (Buscador Global)
if (e.key === 'F3') {
e.preventDefault();
document.getElementById('pos-search-input')?.focus();
}
// F6: Accesos Rápidos
if (e.key === 'F6') {
e.preventDefault();
// Foco en el primer botón de los accesos
const firstShortcut = document.querySelector('.shortcut-card button');
(firstShortcut as HTMLElement)?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
@@ -122,6 +179,49 @@ export default function UniversalPosPage() {
}
};
const handleRemoveShortcut = async (productId: number, e: React.MouseEvent) => {
e.stopPropagation(); // Evitar disparar la selección de producto
try {
await productService.removeShortcut(productId);
showToast('Acceso rápido quitado', 'success');
fetchShortcuts();
} catch (error) {
showToast('Error al quitar acceso rápido', 'error');
}
};
const handleAddShortcut = async (productId: number) => {
try {
await productService.addShortcut(productId);
showToast('Acceso rápido agregado', 'success');
fetchShortcuts();
setShowShortcutAdd(false);
} catch (error) {
showToast('Error al agregar acceso rápido', 'error');
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setShortcuts((items) => {
const oldIndex = items.findIndex(i => i.productId === active.id);
const newIndex = items.findIndex(i => i.productId === over.id);
const newOrder = arrayMove(items, oldIndex, newIndex);
// Persistir en backend
productService.reorderShortcuts(newOrder.map(s => s.productId))
.catch(e => {
console.error("Error persistiendo orden", e);
showToast("No se pudo guardar el orden", "error");
fetchShortcuts(); // Rollback local
});
return newOrder;
});
}
};
const handleCheckout = () => {
if (items.length === 0) return showToast("El carrito está vacío", "error");
if (!clientId) setClient(1005, "Consumidor Final");
@@ -212,29 +312,55 @@ export default function UniversalPosPage() {
</div>
<div className="flex-1 bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-6 overflow-y-auto custom-scrollbar">
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Accesos Rápidos</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{catalog.filter(p => p.typeCode === 'PHYSICAL' || p.typeCode === 'BUNDLE').slice(0, 9).map(p => (
<button
key={p.id}
onClick={() => handleProductSelect(p)}
className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 hover:border-blue-400 hover:shadow-md transition-all text-left group flex flex-col justify-between h-24 relative overflow-hidden"
>
{p.typeCode === 'BUNDLE' && (
<div className="absolute top-0 right-0 bg-purple-100 text-purple-600 p-1 rounded-bl-lg">
<Layers size={12} />
</div>
)}
<div className="text-xs font-bold text-slate-700 group-hover:text-blue-700 line-clamp-2 leading-tight pr-4">
{p.name}
</div>
<div>
<div className="text-[10px] font-black text-slate-400 mt-1 uppercase">{p.typeCode === 'BUNDLE' ? 'Combo' : 'Producto'}</div>
<div className="text-sm font-black text-slate-900">$ {p.basePrice.toLocaleString()}</div>
</div>
</button>
))}
</div>
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4 flex justify-between items-center">
Accesos Rápidos
<span className="text-[10px] lowercase font-bold">{shortcuts.length} / 9</span>
</h3>
{loadingShortcuts ? (
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
<div className="w-8 h-8 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
<span className="text-[10px] font-bold uppercase tracking-widest">Cargando accesos...</span>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SortableContext
items={shortcuts.map(s => s.productId)}
strategy={rectSortingStrategy}
>
<AnimatePresence>
{shortcuts.map(s => (
<div key={s.productId} className="shortcut-card">
<DraggableShortcutCard
id={s.productId}
product={s.product}
onSelect={handleProductSelect}
onRemove={handleRemoveShortcut}
/>
</div>
))}
{shortcuts.length < 9 && (
<button
onClick={() => setShowShortcutAdd(true)}
className="bg-white/50 p-4 rounded-2xl border-2 border-dashed border-slate-300 text-slate-400 hover:border-blue-400 hover:text-blue-500 hover:bg-white transition-all flex flex-col items-center justify-center gap-2 h-32 group"
>
<div className="p-2 bg-slate-100 rounded-full group-hover:bg-blue-50 transition-colors">
<PlusCircle size={24} />
</div>
<span className="text-[10px] font-black uppercase tracking-widest">Anclar Acceso</span>
</button>
)}
</AnimatePresence>
</SortableContext>
</div>
</DndContext>
)}
</div>
</div>
@@ -376,6 +502,18 @@ export default function UniversalPosPage() {
/>
)}
</AnimatePresence>
{/* MODAL DE AGREGAR ACCESO RÁPIDO */}
<AnimatePresence>
{showShortcutAdd && (
<ShortcutAddModal
catalog={catalog}
existingIds={shortcuts.map(s => s.productId)}
onClose={() => setShowShortcutAdd(false)}
onAdd={handleAddShortcut}
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -55,5 +55,24 @@ export const productService = {
getBundleComponents: async (bundleId: number): Promise<ProductBundleComponent[]> => {
const response = await api.get<ProductBundleComponent[]>(`/products/${bundleId}/components`);
return response.data;
},
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
getShortcuts: async (): Promise<any[]> => {
const response = await api.get('/products/shortcuts');
return response.data;
},
addShortcut: async (productId: number): Promise<void> => {
await api.post(`/products/shortcuts/${productId}`);
},
removeShortcut: async (productId: number): Promise<void> => {
await api.delete(`/products/shortcuts/${productId}`);
},
reorderShortcuts: async (productIds: number[]): Promise<void> => {
await api.patch('/products/shortcuts/order', productIds);
}
};
};