diff --git a/HashGen/HashGen.csproj b/HashGen/HashGen.csproj
deleted file mode 100644
index 8184560..0000000
--- a/HashGen/HashGen.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Exe
- net10.0
- enable
- enable
-
-
-
-
-
-
-
diff --git a/HashGen/Program.cs b/HashGen/Program.cs
deleted file mode 100644
index 74270d4..0000000
--- a/HashGen/Program.cs
+++ /dev/null
@@ -1 +0,0 @@
-System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234"));
diff --git a/frontend/counter-panel/package-lock.json b/frontend/counter-panel/package-lock.json
index a29c369..3ad429c 100644
--- a/frontend/counter-panel/package-lock.json
+++ b/frontend/counter-panel/package-lock.json
@@ -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",
diff --git a/frontend/counter-panel/package.json b/frontend/counter-panel/package.json
index 7ca2570..6ce0026 100644
--- a/frontend/counter-panel/package.json
+++ b/frontend/counter-panel/package.json
@@ -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",
diff --git a/frontend/counter-panel/src/components/POS/DraggableShortcutCard.tsx b/frontend/counter-panel/src/components/POS/DraggableShortcutCard.tsx
new file mode 100644
index 0000000..8007737
--- /dev/null
+++ b/frontend/counter-panel/src/components/POS/DraggableShortcutCard.tsx
@@ -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 (
+
+
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)}`}
+ >
+
+
+
+
+
+ {product.companyName || 'Empresa General'}
+
+ {product.typeCode === 'BUNDLE' && }
+
+
+ {product.name}
+
+
+
+
+
+
+ {product.typeCode === 'BUNDLE' ? 'Combo' :
+ product.typeCode === 'CLASSIFIED_AD' ? 'Aviso' : 'Producto'}
+
+ {product.sku &&
#{product.sku}
}
+
+
+ $
+ {product.basePrice.toLocaleString()}
+
+
+
+ {/* Sutil brillo al hover */}
+
+
+
+ );
+}
diff --git a/frontend/counter-panel/src/components/POS/ProductSearch.tsx b/frontend/counter-panel/src/components/POS/ProductSearch.tsx
index 5e53af5..2262cda 100644
--- a/frontend/counter-panel/src/components/POS/ProductSearch.tsx
+++ b/frontend/counter-panel/src/components/POS/ProductSearch.tsx
@@ -51,6 +51,7 @@ export default function ProductSearch({ products, onSelect }: Props) {
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 (
+
+
+
+
+
Anclar Producto
+
Añadir a accesos rápidos
+
+
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+ {filtered.length === 0 ? (
+
+
No se encontraron productos disponibles
+
+ ) : (
+ filtered.map(p => (
+
+ ))
+ )}
+
+
+
+
+
+ Selecciona un producto para anclarlo a tu grilla
+
+
+
+
+ );
+}
diff --git a/frontend/counter-panel/src/pages/UniversalPosPage.tsx b/frontend/counter-panel/src/pages/UniversalPosPage.tsx
index 19330f7..facfc77 100644
--- a/frontend/counter-panel/src/pages/UniversalPosPage.tsx
+++ b/frontend/counter-panel/src/pages/UniversalPosPage.tsx
@@ -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
([]);
+ const [shortcuts, setShortcuts] = useState([]);
+ 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() {
-
Accesos Rápidos
-
- {catalog.filter(p => p.typeCode === 'PHYSICAL' || p.typeCode === 'BUNDLE').slice(0, 9).map(p => (
-
- ))}
-
+
+ Accesos Rápidos
+ {shortcuts.length} / 9
+
+
+ {loadingShortcuts ? (
+
+
+
Cargando accesos...
+
+ ) : (
+
+
+
s.productId)}
+ strategy={rectSortingStrategy}
+ >
+
+ {shortcuts.map(s => (
+
+
+
+ ))}
+
+ {shortcuts.length < 9 && (
+
+ )}
+
+
+
+
+ )}
@@ -376,6 +502,18 @@ export default function UniversalPosPage() {
/>
)}
+
+ {/* MODAL DE AGREGAR ACCESO RÁPIDO */}
+
+ {showShortcutAdd && (
+ s.productId)}
+ onClose={() => setShowShortcutAdd(false)}
+ onAdd={handleAddShortcut}
+ />
+ )}
+
);
}
\ No newline at end of file
diff --git a/frontend/counter-panel/src/services/productService.ts b/frontend/counter-panel/src/services/productService.ts
index a00ab3d..ac66882 100644
--- a/frontend/counter-panel/src/services/productService.ts
+++ b/frontend/counter-panel/src/services/productService.ts
@@ -55,5 +55,24 @@ export const productService = {
getBundleComponents: async (bundleId: number): Promise => {
const response = await api.get(`/products/${bundleId}/components`);
return response.data;
+ },
+
+ // --- SHORTCUTS (ACCESOS RÁPIDOS) ---
+
+ getShortcuts: async (): Promise => {
+ const response = await api.get('/products/shortcuts');
+ return response.data;
+ },
+
+ addShortcut: async (productId: number): Promise => {
+ await api.post(`/products/shortcuts/${productId}`);
+ },
+
+ removeShortcut: async (productId: number): Promise => {
+ await api.delete(`/products/shortcuts/${productId}`);
+ },
+
+ reorderShortcuts: async (productIds: number[]): Promise => {
+ await api.patch('/products/shortcuts/order', productIds);
}
-};
\ No newline at end of file
+};
diff --git a/src/SIGCM.API/Controllers/ProductsController.cs b/src/SIGCM.API/Controllers/ProductsController.cs
index b8c1767..5fe43de 100644
--- a/src/SIGCM.API/Controllers/ProductsController.cs
+++ b/src/SIGCM.API/Controllers/ProductsController.cs
@@ -141,4 +141,46 @@ public class ProductsController : ControllerBase
return Ok(result);
}
+
+ // --- SHORTCUTS (ACCESOS RÁPIDOS) ---
+
+ [HttpGet("shortcuts")]
+ public async Task 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 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 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 UpdateShortcutsOrder([FromBody] List productIds)
+ {
+ var userIdClaim = User.FindFirst("Id")?.Value;
+ if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
+
+ await _repository.UpdateShortcutsOrderAsync(userId, productIds);
+ return NoContent();
+ }
}
\ No newline at end of file
diff --git a/src/SIGCM.Domain/Entities/UserProductShortcut.cs b/src/SIGCM.Domain/Entities/UserProductShortcut.cs
new file mode 100644
index 0000000..08fc10d
--- /dev/null
+++ b/src/SIGCM.Domain/Entities/UserProductShortcut.cs
@@ -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; }
+}
diff --git a/src/SIGCM.Domain/Interfaces/IProductRepository.cs b/src/SIGCM.Domain/Interfaces/IProductRepository.cs
index 83b9495..dc0ba7d 100644
--- a/src/SIGCM.Domain/Interfaces/IProductRepository.cs
+++ b/src/SIGCM.Domain/Interfaces/IProductRepository.cs
@@ -17,4 +17,10 @@ public interface IProductRepository
Task GetCurrentPriceAsync(int productId, DateTime date);
Task AddPriceAsync(ProductPrice price);
Task DeleteAsync(int id);
+
+ // --- SHORTCUTS (ACCESOS RÁPIDOS) ---
+ Task> GetUserShortcutsAsync(int userId);
+ Task AddShortcutAsync(int userId, int productId);
+ Task RemoveShortcutAsync(int userId, int productId);
+ Task UpdateShortcutsOrderAsync(int userId, List productIds);
}
\ No newline at end of file
diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs
index b13ebff..098131e 100644
--- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs
+++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs
@@ -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);
diff --git a/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs
index e2a43c6..6a8d4ae 100644
--- a/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs
+++ b/src/SIGCM.Infrastructure/Repositories/ProductRepository.cs
@@ -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> 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(
+ 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(
+ "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(
+ "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 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;
+ }
+ }
}
\ No newline at end of file