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