diff --git a/stock_picking_batch_custom/README.rst b/stock_picking_batch_custom/README.rst index 9893a8f..964a1a7 100644 --- a/stock_picking_batch_custom/README.rst +++ b/stock_picking_batch_custom/README.rst @@ -1,13 +1,66 @@ -=============================== +============================ Stock Picking Batch Custom -=============================== +============================ -.. contents:: - :local: +Visión general +============== +Este módulo amplía las operaciones detalladas y añade un resumen por producto +en los lotes de picking: -.. include:: readme/DESCRIPTION.rst -.. include:: readme/INSTALL.rst -.. include:: readme/CONFIGURE.rst -.. include:: readme/USAGE.rst -.. include:: readme/CONTRIBUTORS.rst -.. include:: readme/CREDITS.rst +- ``picking_partner_id`` (Partner del albarán) para identificar cliente/proveedor. +- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. +- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se ha + recolectado. +- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho, + pendiente), categoría y el check de recogido consolidado. + +Instalación +=========== + +Actualizar o instalar el módulo: + +:: + + docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom + +Configuración +============= + +No requiere configuración adicional. Para usar las columnas: + +- Abrir un **Lote de picking**. +- Ir a la pestaña **Detailed Operations**. +- Abrir el **selector de columnas** y activar *Partner*, *Product Category* y *Collected* según necesidad. + +Uso +=== + +1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote. +2. Pestaña **Detailed Operations**: usa el selector de columnas para activar: + + - **Partner** (``picking_partner_id``) para ver el cliente/proveedor. + - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría. + - **Collected** (``is_collected``) para marcar manualmente líneas recolectadas. + +3. Pestaña **Product Summary**: consulta los totales por producto (demandado, + hecho y pendiente) y marca el check de recogido consolidado si corresponde. + +4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga. + +Contribuidores +============== + +* Criptomart + +Créditos +======== + +Autor +----- + +* Criptomart + +Financiador +----------- + +* Elika Bilbo diff --git a/stock_picking_batch_custom/__manifest__.py b/stock_picking_batch_custom/__manifest__.py index 4882701..27e80bd 100644 --- a/stock_picking_batch_custom/__manifest__.py +++ b/stock_picking_batch_custom/__manifest__.py @@ -13,6 +13,8 @@ "stock_picking_batch", ], "data": [ + "security/ir.model.access.csv", "views/stock_move_line_views.xml", + "views/stock_picking_batch_views.xml", ], } diff --git a/stock_picking_batch_custom/models/__init__.py b/stock_picking_batch_custom/models/__init__.py index 5a228fe..24709d5 100644 --- a/stock_picking_batch_custom/models/__init__.py +++ b/stock_picking_batch_custom/models/__init__.py @@ -1 +1,2 @@ from . import stock_move_line # noqa: F401 +from . import stock_picking_batch # noqa: F401 diff --git a/stock_picking_batch_custom/models/stock_move_line.py b/stock_picking_batch_custom/models/stock_move_line.py index cb4ad48..82bd6d2 100644 --- a/stock_picking_batch_custom/models/stock_move_line.py +++ b/stock_picking_batch_custom/models/stock_move_line.py @@ -10,8 +10,14 @@ class StockMoveLine(models.Model): product_categ_id = fields.Many2one( comodel_name="product.category", - string="Product Category", + string="Product Category (Batch)", related="product_id.categ_id", store=True, readonly=True, ) + + is_collected = fields.Boolean( + string="Collected", + default=False, + copy=False, + ) diff --git a/stock_picking_batch_custom/models/stock_picking_batch.py b/stock_picking_batch_custom/models/stock_picking_batch.py new file mode 100644 index 0000000..c2c77fc --- /dev/null +++ b/stock_picking_batch_custom/models/stock_picking_batch.py @@ -0,0 +1,202 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api +from odoo import fields +from odoo import models + + +class StockPickingBatch(models.Model): + _inherit = "stock.picking.batch" + + summary_line_ids = fields.One2many( + comodel_name="stock.picking.batch.summary.line", + inverse_name="batch_id", + string="Product Summary", + compute="_compute_summary_line_ids", + store=True, + readonly=True, + ) + + @api.depends( + "move_line_ids.move_id.product_id", + "move_line_ids.move_id.product_uom_qty", + "move_line_ids.move_id.quantity", + "move_line_ids.move_id.product_uom", + "move_line_ids.move_id.state", + ) + def _compute_summary_line_ids(self): + """Aggregate move quantities per product and keep collected flag. + + - Demand: move.product_uom_qty converted to product UoM. + - Done: move.quantity converted to product UoM. + - Pending: demand - done. + - Keep is_collected value if product already present. + - Skip cancelled moves. + """ + + for batch in self: + # Base: limpiar si no hay líneas + if not batch.move_line_ids: + batch.summary_line_ids = [fields.Command.clear()] + continue + + previous_collected = { + line.product_id.id: line.is_collected + for line in batch.summary_line_ids + if line.product_id + } + # Use regular dict to avoid accidental creation of empty entries + aggregates = {} + + # Demand per move (count once per move) + for move in batch.move_ids: + if move.state == "cancel" or not move.product_id: + continue + product = move.product_id + product_uom = product.uom_id + demand = move.product_uom._compute_quantity( + move.product_uom_qty, product_uom, rounding_method="HALF-UP" + ) + if product.id not in aggregates: + aggregates[product.id] = { + "product_id": product.id, + "product_uom_id": product_uom.id, + "qty_demanded": 0.0, + "qty_done": 0.0, + } + aggregates[product.id]["qty_demanded"] += demand + + # Done per move line + for line in batch.move_line_ids: + move = line.move_id + if move and move.state == "cancel": + continue + product = line.product_id + if not product: + continue + product_uom = product.uom_id + done = line.product_uom_id._compute_quantity( + line.quantity, product_uom, rounding_method="HALF-UP" + ) + if product.id not in aggregates: + aggregates[product.id] = { + "product_id": product.id, + "product_uom_id": product_uom.id, + "qty_demanded": 0.0, + "qty_done": 0.0, + } + aggregates[product.id]["qty_done"] += done + + commands = [] + products_in_totals = set() + existing_by_product = { + line.product_id.id: line for line in batch.summary_line_ids + } + + for product_id, values in aggregates.items(): + # Double-check: skip if product_id is not valid + if not product_id or not values.get("product_id"): + continue + products_in_totals.add(product_id) + existing_line = existing_by_product.get(product_id) + is_collected = previous_collected.get(product_id, False) + if existing_line: + commands.append( + fields.Command.update( + existing_line.id, + { + "product_uom_id": values["product_uom_id"], + "qty_demanded": values["qty_demanded"], + "qty_done": values["qty_done"], + "is_collected": is_collected, + }, + ) + ) + else: + # Ensure all required fields are present before create + if values["product_id"] and values["product_uom_id"]: + commands.append( + fields.Command.create( + { + "product_id": values["product_id"], + "product_uom_id": values["product_uom_id"], + "qty_demanded": values["qty_demanded"], + "qty_done": values["qty_done"], + "is_collected": is_collected, + } + ) + ) + + obsolete_lines = [ + fields.Command.unlink(line.id) + for line in batch.summary_line_ids + if line.product_id.id not in products_in_totals + ] + + if commands or obsolete_lines: + batch.summary_line_ids = commands + obsolete_lines + else: + batch.summary_line_ids = [fields.Command.clear()] + + +class StockPickingBatchSummaryLine(models.Model): + _name = "stock.picking.batch.summary.line" + _description = "Batch Product Summary Line" + _order = "product_categ_id, product_id" + + _sql_constraints = [ + ( + "product_required", + "CHECK(product_id IS NOT NULL)", + "Product is required for summary lines.", + ), + ] + + batch_id = fields.Many2one( + comodel_name="stock.picking.batch", + ondelete="cascade", + required=True, + index=True, + ) + product_id = fields.Many2one( + comodel_name="product.product", + required=True, + index=True, + ) + product_categ_id = fields.Many2one( + comodel_name="product.category", + string="Product Category", + related="product_id.categ_id", + store=True, + readonly=True, + ) + product_uom_id = fields.Many2one( + comodel_name="uom.uom", + string="Unit of Measure", + required=True, + ) + qty_demanded = fields.Float( + string="Demanded Quantity", + digits="Product Unit of Measure", + required=True, + default=0.0, + ) + qty_done = fields.Float( + string="Done Quantity", + digits="Product Unit of Measure", + required=True, + default=0.0, + ) + qty_pending = fields.Float( + string="Pending Quantity", + digits="Product Unit of Measure", + compute="_compute_qty_pending", + store=True, + ) + is_collected = fields.Boolean(string="Collected", default=False) + + @api.depends("qty_demanded", "qty_done") + def _compute_qty_pending(self): + for line in self: + line.qty_pending = line.qty_demanded - line.qty_done diff --git a/stock_picking_batch_custom/readme/DESCRIPTION.rst b/stock_picking_batch_custom/readme/DESCRIPTION.rst index d8fc673..da6edad 100644 --- a/stock_picking_batch_custom/readme/DESCRIPTION.rst +++ b/stock_picking_batch_custom/readme/DESCRIPTION.rst @@ -1,11 +1,14 @@ -Este módulo añade dos columnas opcionales en las operaciones detalladas de los -lotes de picking: +Este módulo amplía las operaciones detalladas y añade un resumen por producto +en los lotes de picking: - ``picking_partner_id`` (Partner del albarán) para que el personal de almacén - pueda identificar rápidamente el cliente/proveedor asociado. -- ``product_categ_id`` (Categoría de producto) para permitir ordenación y - agrupación por categoría. + identifique rápido el cliente/proveedor. +- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. +- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se + ha recolectado. +- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho, + pendiente), categoría y el check de recogido consolidado. -Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del -lote, de modo que el usuario puede activarlas desde el selector de columnas sin -cargar la vista por defecto. +Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote, +de modo que el usuario puede activarlas desde el selector de columnas sin +recargar la vista por defecto. diff --git a/stock_picking_batch_custom/readme/USAGE.rst b/stock_picking_batch_custom/readme/USAGE.rst index d652a4e..89f41e8 100644 --- a/stock_picking_batch_custom/readme/USAGE.rst +++ b/stock_picking_batch_custom/readme/USAGE.rst @@ -6,5 +6,9 @@ Uso - **Partner** (``picking_partner_id``) para ver el cliente/proveedor. - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría. + - **Collected** (``is_collected``) para marcar manualmente líneas recolectadas. -3. Ordena o agrupa por la columna de categoría según convenga. +3. Pestaña **Product Summary**: consulta los totales por producto (demandado, + hecho y pendiente) y marca el check de recogido consolidado si corresponde. + +4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga. diff --git a/stock_picking_batch_custom/security/ir.model.access.csv b/stock_picking_batch_custom/security/ir.model.access.csv new file mode 100644 index 0000000..57ebabc --- /dev/null +++ b/stock_picking_batch_custom/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_picking_batch_summary_line_user,stock_picking_batch_summary_line_user,model_stock_picking_batch_summary_line,base.group_user,1,1,1,0 diff --git a/stock_picking_batch_custom/tests/__init__.py b/stock_picking_batch_custom/tests/__init__.py new file mode 100644 index 0000000..161ba0b --- /dev/null +++ b/stock_picking_batch_custom/tests/__init__.py @@ -0,0 +1 @@ +from . import test_batch_summary # noqa: F401 diff --git a/stock_picking_batch_custom/tests/test_batch_summary.py b/stock_picking_batch_custom/tests/test_batch_summary.py new file mode 100644 index 0000000..e8139e9 --- /dev/null +++ b/stock_picking_batch_custom/tests/test_batch_summary.py @@ -0,0 +1,289 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("-at_install", "post_install") +class TestBatchSummary(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + cls.location_src = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.env.ref("stock.stock_location_customers") + cls.picking_type = cls.env.ref("stock.picking_type_out") + + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + } + ) + + cls.batch = cls.env["stock.picking.batch"].create( + {"name": "Batch Test", "picking_type_id": cls.picking_type.id} + ) + + cls.picking1 = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + "batch_id": cls.batch.id, + } + ) + cls.picking2 = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + "batch_id": cls.batch.id, + } + ) + + # Helpers + def _add_move(self, picking, qty_demanded, qty_done, uom): + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": qty_demanded, + "product_uom": uom.id, + "picking_id": picking.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + if qty_done: + self.env["stock.move.line"].create( + { + "move_id": move.id, + "picking_id": picking.id, + "product_id": self.product.id, + "product_uom_id": self.product.uom_id.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + "quantity": qty_done, + } + ) + return move + + def _recompute_batch(self): + self.batch.invalidate_recordset() + self.batch._compute_summary_line_ids() + + def _create_batch_with_pickings(self): + batch = self.env["stock.picking.batch"].create( + {"name": "Batch Flow", "picking_type_id": self.picking_type.id} + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 2.0, + "product_uom": self.uom_unit.id, + "picking_id": picking.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + self.env["stock.move.line"].create( + { + "move_id": move.id, + "picking_id": picking.id, + "product_id": self.product.id, + "product_uom_id": self.uom_unit.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + "quantity": 2.0, + } + ) + picking.batch_id = batch.id + return batch + + # Tests + def test_totals_and_pending_with_conversion(self): + """Totals aggregate per product with UoM conversion and pending.""" + + # demand 12 units, done 5 units + self._add_move(self.picking1, qty_demanded=12, qty_done=5, uom=self.uom_unit) + # demand 2 dozens (24 units), done 6 units + self._add_move(self.picking2, qty_demanded=2, qty_done=6, uom=self.uom_dozen) + + self._recompute_batch() + + self.assertEqual(len(self.batch.summary_line_ids), 1) + line = self.batch.summary_line_ids + self.assertEqual(line.product_id, self.product) + self.assertEqual(line.product_uom_id, self.uom_unit) + self.assertAlmostEqual(line.qty_demanded, 36.0) + self.assertAlmostEqual(line.qty_done, 11.0) + self.assertAlmostEqual(line.qty_pending, 25.0) + + def test_collected_flag_preserved_on_recompute(self): + """Collected stays checked after totals change.""" + + self._add_move(self.picking1, qty_demanded=1, qty_done=1, uom=self.uom_unit) + self._recompute_batch() + + line = self.batch.summary_line_ids + line.is_collected = True + + # Add more demand/done to trigger an update + self._add_move(self.picking1, qty_demanded=3, qty_done=2, uom=self.uom_unit) + self._recompute_batch() + + self.assertTrue(self.batch.summary_line_ids.is_collected) + + def test_cancelled_moves_are_ignored(self): + """Cancelled moves do not count in the summary and lines are removed.""" + + move1 = self._add_move( + self.picking1, qty_demanded=4, qty_done=2, uom=self.uom_unit + ) + move2 = self._add_move( + self.picking2, qty_demanded=6, qty_done=3, uom=self.uom_unit + ) + self._recompute_batch() + self.assertEqual(len(self.batch.summary_line_ids), 1) + + move1.write({"state": "cancel"}) + move2.write({"state": "cancel"}) + self._recompute_batch() + + self.assertFalse(self.batch.summary_line_ids) + + def test_no_required_product_error_on_confirm(self): + """Confirming batch with pickings must not create summary lines without product.""" + + batch = self._create_batch_with_pickings() + + # Trigger compute via confirm flow + batch.action_confirm() + + self.assertTrue(batch.summary_line_ids) + self.assertFalse( + batch.summary_line_ids.filtered(lambda line: not line.product_id) + ) + + def test_empty_batch_add_pickings_then_confirm(self): + """Create empty draft batch, add multiple pickings, then confirm.""" + + # 1. Create empty batch in draft state + batch = self.env["stock.picking.batch"].create( + {"name": "Batch Empty Start", "picking_type_id": self.picking_type.id} + ) + self.assertEqual(batch.state, "draft") + self.assertFalse(batch.picking_ids) + self.assertFalse(batch.summary_line_ids) + + # 2. Create pickings with moves (without batch assignment) + product2 = self.env["product.product"].create( + { + "name": "Test Product 2", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + } + ) + + picking_a = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 5.0, + "product_uom": self.uom_unit.id, + "picking_id": picking_a.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + + picking_b = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + self.env["stock.move"].create( + { + "name": product2.name, + "product_id": product2.id, + "product_uom_qty": 10.0, + "product_uom": self.uom_unit.id, + "picking_id": picking_b.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + + picking_c = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 3.0, + "product_uom": self.uom_unit.id, + "picking_id": picking_c.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + + # 3. Add pickings to the batch + picking_a.batch_id = batch + picking_b.batch_id = batch + picking_c.batch_id = batch + + self.assertEqual(len(batch.picking_ids), 3) + + # 4. Confirm the batch — this should not raise product_id required error + batch.action_confirm() + + self.assertEqual(batch.state, "in_progress") + + # 5. Verify summary lines are correct + self.assertTrue(batch.summary_line_ids) + self.assertFalse( + batch.summary_line_ids.filtered(lambda line: not line.product_id) + ) + + # Two products expected + products_in_summary = batch.summary_line_ids.mapped("product_id") + self.assertIn(self.product, products_in_summary) + self.assertIn(product2, products_in_summary) + + # Check aggregated quantities + line_product1 = batch.summary_line_ids.filtered( + lambda line: line.product_id == self.product + ) + self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3 + + line_product2 = batch.summary_line_ids.filtered( + lambda line: line.product_id == product2 + ) + self.assertAlmostEqual(line_product2.qty_demanded, 10.0) diff --git a/stock_picking_batch_custom/views/stock_move_line_views.xml b/stock_picking_batch_custom/views/stock_move_line_views.xml index a000a97..b857a2c 100644 --- a/stock_picking_batch_custom/views/stock_move_line_views.xml +++ b/stock_picking_batch_custom/views/stock_move_line_views.xml @@ -11,6 +11,9 @@ + + + diff --git a/stock_picking_batch_custom/views/stock_picking_batch_views.xml b/stock_picking_batch_custom/views/stock_picking_batch_views.xml new file mode 100644 index 0000000..43b3ae2 --- /dev/null +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -0,0 +1,31 @@ + + + + stock.picking.batch.summary.line.tree + stock.picking.batch.summary.line + + + + + + + + + + + + + + + stock.picking.batch.form.summary + stock.picking.batch + + + + + + + + + + diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index b88bbee..3f76c3c 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -694,6 +694,70 @@ return; } + // Shared handler for add-to-cart to reuse across grid/document listeners + var handleAddToCart = function (e) { + var cartBtn = e.target.closest(".add-to-cart-btn"); + if (!cartBtn) return; + + e.preventDefault(); + var form = cartBtn.closest(".add-to-cart-form"); + if (!form) return; + + var productId = form.getAttribute("data-product-id"); + var productName = form.getAttribute("data-product-name") || "Product"; + var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0; + var quantityInput = form.querySelector(".product-qty"); + var quantity = quantityInput ? parseFloat(quantityInput.value) : 1; + + // Block add-to-cart if product is flagged out of stock (from template) + var isOutOfStock = + (form.getAttribute("data-out-of-stock") || "false") === "true" || + (cartBtn.getAttribute("data-out-of-stock") || "false") === "true"; + + // Fallback guards in case cached markup drops the data attribute + if (!isOutOfStock) { + var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase(); + var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase(); + var iconEl = cartBtn.querySelector("i"); + var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban"); + + if ( + hasBanIcon || + btnTitle.includes("out of stock") || + btnTitle.includes("sin stock") || + btnAria.includes("out of stock") || + btnAria.includes("sin stock") + ) { + isOutOfStock = true; + } + } + if (isOutOfStock) { + var labels = self._getLabels(); + self._showNotification( + labels.out_of_stock || "Product is out of stock", + "warning" + ); + return; + } + + console.log("Adding:", { + productId: productId, + productName: productName, + productPrice: productPrice, + quantity: quantity, + }); + + if (quantity > 0) { + self._addToCart(productId, productName, productPrice, quantity); + } else { + var labels2 = self._getLabels(); + self._showNotification( + labels2.invalid_quantity || "Please enter a valid quantity", + "warning" + ); + } + }; + // First, adjust quantity steps for all existing inputs var unitInputs = document.querySelectorAll(".product-qty"); console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ==="); @@ -779,67 +843,16 @@ } }); - // Add to cart button (via event delegation) - productsGrid.addEventListener("click", function (e) { - var cartBtn = e.target.closest(".add-to-cart-btn"); - if (!cartBtn) return; + // Add to cart button (via event delegation on grid) + productsGrid.addEventListener("click", handleAddToCart); - e.preventDefault(); - var form = cartBtn.closest(".add-to-cart-form"); - var productId = form.getAttribute("data-product-id"); - var productName = form.getAttribute("data-product-name") || "Product"; - var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0; - var quantityInput = form.querySelector(".product-qty"); - var quantity = quantityInput ? parseFloat(quantityInput.value) : 1; - - // Block add-to-cart if product is flagged out of stock (from template) - var isOutOfStock = - (form.getAttribute("data-out-of-stock") || "false") === "true" || - (cartBtn.getAttribute("data-out-of-stock") || "false") === "true"; - - // Fallback guards in case cached markup drops the data attribute - if (!isOutOfStock) { - var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase(); - var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase(); - var iconEl = cartBtn.querySelector("i"); - var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban"); - - if ( - hasBanIcon || - btnTitle.includes("out of stock") || - btnTitle.includes("sin stock") || - btnAria.includes("out of stock") || - btnAria.includes("sin stock") - ) { - isOutOfStock = true; - } - } - if (isOutOfStock) { - var labels = self._getLabels(); - self._showNotification( - labels.out_of_stock || "Product is out of stock", - "warning" - ); - return; - } - - console.log("Adding:", { - productId: productId, - productName: productName, - productPrice: productPrice, - quantity: quantity, - }); - - if (quantity > 0) { - self._addToCart(productId, productName, productPrice, quantity); - } else { - var labels = self._getLabels(); - self._showNotification( - labels.invalid_quantity || "Please enter a valid quantity", - "warning" - ); - } - }); + // Also attach a document-level delegation as fallback for dynamically + // inserted products (infinite scroll) in case the grid listener is lost + // after DOM replacement. + if (!this._docCartListenerAttached) { + document.addEventListener("click", handleAddToCart, true); + this._docCartListenerAttached = true; + } }, _addToCart: function (productId, productName, productPrice, quantity) {