Fix: Accesos Directos a Productos Desde Caja Administrables
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1 +0,0 @@
|
||||
System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234"));
|
||||
56
frontend/counter-panel/package-lock.json
generated
56
frontend/counter-panel/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)..."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,4 +141,46 @@ public class ProductsController : ControllerBase
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
|
||||
[HttpGet("shortcuts")]
|
||||
public async Task<IActionResult> GetShortcuts()
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
var shortcuts = await _repository.GetUserShortcutsAsync(userId);
|
||||
return Ok(shortcuts);
|
||||
}
|
||||
|
||||
[HttpPost("shortcuts/{productId}")]
|
||||
public async Task<IActionResult> AddShortcut(int productId)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.AddShortcutAsync(userId, productId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("shortcuts/{productId}")]
|
||||
public async Task<IActionResult> RemoveShortcut(int productId)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.RemoveShortcutAsync(userId, productId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPatch("shortcuts/order")]
|
||||
public async Task<IActionResult> UpdateShortcutsOrder([FromBody] List<int> productIds)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.UpdateShortcutsOrderAsync(userId, productIds);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
12
src/SIGCM.Domain/Entities/UserProductShortcut.cs
Normal file
12
src/SIGCM.Domain/Entities/UserProductShortcut.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class UserProductShortcut
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public int ProductId { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
// Propiedad auxiliar para el frontend
|
||||
public Product? Product { get; set; }
|
||||
}
|
||||
@@ -17,4 +17,10 @@ public interface IProductRepository
|
||||
Task<decimal> GetCurrentPriceAsync(int productId, DateTime date);
|
||||
Task AddPriceAsync(ProductPrice price);
|
||||
Task DeleteAsync(int id);
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
Task<IEnumerable<UserProductShortcut>> GetUserShortcutsAsync(int userId);
|
||||
Task AddShortcutAsync(int userId, int productId);
|
||||
Task RemoveShortcutAsync(int userId, int productId);
|
||||
Task UpdateShortcutsOrderAsync(int userId, List<int> productIds);
|
||||
}
|
||||
@@ -189,6 +189,19 @@ BEGIN
|
||||
FOREIGN KEY (ChildProductId) REFERENCES Products(Id)
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserProductShortcuts')
|
||||
BEGIN
|
||||
CREATE TABLE UserProductShortcuts (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
UserId INT NOT NULL,
|
||||
ProductId INT NOT NULL,
|
||||
DisplayOrder INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||||
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE,
|
||||
CONSTRAINT UQ_User_Product UNIQUE (UserId, ProductId)
|
||||
);
|
||||
END
|
||||
";
|
||||
// Ejecutar creación de tablas base
|
||||
await connection.ExecuteAsync(schemaSql);
|
||||
@@ -353,6 +366,20 @@ END
|
||||
FOREIGN KEY (CategoryPricingId) REFERENCES CategoryPricing(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
|
||||
-- UserProductShortcuts Table
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserProductShortcuts')
|
||||
BEGIN
|
||||
CREATE TABLE UserProductShortcuts (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
UserId INT NOT NULL,
|
||||
ProductId INT NOT NULL,
|
||||
DisplayOrder INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||||
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE,
|
||||
CONSTRAINT UQ_User_Product UNIQUE (UserId, ProductId)
|
||||
);
|
||||
END
|
||||
";
|
||||
await connection.ExecuteAsync(migrationSql);
|
||||
|
||||
|
||||
@@ -238,4 +238,82 @@ public class ProductRepository : IProductRepository
|
||||
// 4. Eliminar el producto final
|
||||
await conn.ExecuteAsync("DELETE FROM Products WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
|
||||
public async Task<IEnumerable<UserProductShortcut>> GetUserShortcutsAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT s.*, p.*, pt.Code as TypeCode, pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM UserProductShortcuts s
|
||||
JOIN Products p ON s.ProductId = p.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
WHERE s.UserId = @UserId AND p.IsActive = 1
|
||||
ORDER BY s.DisplayOrder ASC";
|
||||
|
||||
return await conn.QueryAsync<UserProductShortcut, Product, UserProductShortcut>(
|
||||
sql,
|
||||
(shortcut, product) =>
|
||||
{
|
||||
shortcut.Product = product;
|
||||
return shortcut;
|
||||
},
|
||||
new { UserId = userId },
|
||||
splitOn: "Id"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task AddShortcutAsync(int userId, int productId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// 1. Verificar si ya existe
|
||||
var exists = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM UserProductShortcuts WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { UserId = userId, ProductId = productId });
|
||||
|
||||
if (exists > 0) return;
|
||||
|
||||
// 2. Obtener el orden máximo actual
|
||||
var maxOrder = await conn.ExecuteScalarAsync<int?>(
|
||||
"SELECT MAX(DisplayOrder) FROM UserProductShortcuts WHERE UserId = @UserId",
|
||||
new { UserId = userId }) ?? 0;
|
||||
|
||||
// 3. Insertar
|
||||
var sql = @"
|
||||
INSERT INTO UserProductShortcuts (UserId, ProductId, DisplayOrder)
|
||||
VALUES (@UserId, @ProductId, @DisplayOrder)";
|
||||
await conn.ExecuteAsync(sql, new { UserId = userId, ProductId = productId, DisplayOrder = maxOrder + 1 });
|
||||
}
|
||||
|
||||
public async Task RemoveShortcutAsync(int userId, int productId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM UserProductShortcuts WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { UserId = userId, ProductId = productId });
|
||||
}
|
||||
|
||||
public async Task UpdateShortcutsOrderAsync(int userId, List<int> productIds)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
conn.Open();
|
||||
using var trans = conn.BeginTransaction();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < productIds.Count; i++)
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE UserProductShortcuts SET DisplayOrder = @Order WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { Order = i, UserId = userId, ProductId = productIds[i] },
|
||||
transaction: trans);
|
||||
}
|
||||
trans.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
trans.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user