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

@@ -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>

View File

@@ -1 +0,0 @@
System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234"));

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);
}
};
};

View File

@@ -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();
}
}

View 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; }
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}
}
}