From d93c4c6b7b0c63cafc869347fff3cc8a35a7c829 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 27 Mar 2026 08:45:51 -0500 Subject: [PATCH 1/3] Mecanismo sort para tablas --- backend/api/v1/common/tenant_crud_routes.py | 27 +- backend/api/v1/modules/a76/classes/routes.py | 6 +- backend/api/v1/modules/a76/classes/service.py | 33 + .../a76/clients_and_providers/service.py | 13 + backend/api/v1/modules/a76/common/sorting.py | 61 + .../modules/a76/customs_brokers/services.py | 19 + backend/api/v1/modules/a76/invoices/routes.py | 6 +- .../api/v1/modules/a76/invoices/services.py | 16 + .../a76/items/exports/validators/create.py | 28 +- .../a76/items/exports/validators/update.py | 17 + .../a76/items/imports/validators/common.py | 33 +- .../a76/items/imports/validators/create.py | 28 +- .../a76/items/imports/validators/update.py | 17 + backend/api/v1/modules/a76/items/routes.py | 20 +- backend/api/v1/modules/a76/items/service.py | 44 +- backend/api/v1/modules/a76/parts/service.py | 14 + .../a76/pedmientos/services/pedimentos.py | 20 +- frontend/src/lib/api/dashboard/a76/classes.ts | 7 + frontend/src/lib/api/dashboard/a76/parts.ts | 7 +- .../src/lib/api/dashboard/a76/pedimentos.ts | 24 +- .../dashboard/exchange_rate/columns.ts | 2 +- .../customs_broker_concepts/columns.ts | 2 +- .../dashboard/goods/classes/columns.ts | 139 ++ .../dashboard/goods/classes/data-table.svelte | 164 +++ .../dashboard/goods/parts/columns.ts | 51 +- .../goods/parts/data-table-actions.svelte | 4 +- .../dashboard/goods/parts/data-table.svelte | 104 +- .../components/dashboard/invoices/columns.ts | 5 + .../dashboard/invoices/data-table.svelte | 89 +- .../edit/items/fa/item-sheet-fa.svelte | 86 +- .../edit/items/inv/item-sheet-inv.svelte | 126 +- .../invoices/edit/items/items-tab-form.svelte | 1249 ++++++----------- .../dashboard/pedimentos/columns.ts | 7 + .../dashboard/pedimentos/data-table.svelte | 79 +- frontend/src/lib/utils/items-logic.ts | 85 +- .../goods/fixed-asset-classes/+page.svelte | 123 +- .../routes/dashboard/goods/parts/+page.svelte | 154 +- .../routes/dashboard/invoices/+page.svelte | 28 +- .../routes/dashboard/pedimentos/+page.svelte | 24 +- 39 files changed, 1724 insertions(+), 1237 deletions(-) create mode 100644 backend/api/v1/modules/a76/common/sorting.py create mode 100644 frontend/src/lib/components/dashboard/goods/classes/columns.ts create mode 100644 frontend/src/lib/components/dashboard/goods/classes/data-table.svelte diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index dd389503..01c65047 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -1,5 +1,6 @@ from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union import logging +import inspect from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource @@ -147,6 +148,8 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): @@ -175,15 +178,23 @@ class TenantCRUDRoutes( # Extraer todos los parámetros de búsqueda dinámicamente # Excluimos los parámetros estándar de paginación y control - standard_params = {"company_id", "all_companies", "page", "page_size"} + standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"} filters = { k: v for k, v in request.query_params.items() if k not in standard_params and v is not None and v != "" } + # Determine what parameters the service method accepts + sig = inspect.signature(self.service.get_all) + kwargs = {} + if "sort_by" in sig.parameters: + kwargs["sort_by"] = sort_by + if "sort_order" in sig.parameters: + kwargs["sort_order"] = sort_order + items, total = self.service.get_all( - db, tenant_id, target_company_id, skip, page_size, filters + db, tenant_id, target_company_id, skip, page_size, filters, **kwargs ) @@ -214,6 +225,8 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): @@ -238,8 +251,16 @@ class TenantCRUDRoutes( skip = (page - 1) * page_size + # Determine what parameters the service method accepts + sig = inspect.signature(self.service.get_all) + kwargs = {} + if "sort_by" in sig.parameters: + kwargs["sort_by"] = sort_by + if "sort_order" in sig.parameters: + kwargs["sort_order"] = sort_order + items, total = self.service.get_all( - db, tenant_id, target_company_id, skip, page_size, None + db, tenant_id, target_company_id, skip, page_size, None, **kwargs ) return { diff --git a/backend/api/v1/modules/a76/classes/routes.py b/backend/api/v1/modules/a76/classes/routes.py index 389bbbd5..62dc531b 100644 --- a/backend/api/v1/modules/a76/classes/routes.py +++ b/backend/api/v1/modules/a76/classes/routes.py @@ -2,7 +2,7 @@ Endpoints API para gestión de clases SCAII y SCAF """ -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session @@ -33,6 +33,8 @@ async def get_classes_with_fa_data( company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(1000, ge=1, le=1000, description="Page size"), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", description="Sort order (asc/desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -50,6 +52,8 @@ async def get_classes_with_fa_data( company_id=company_id, skip=skip, limit=page_size, + sort_by=sort_by, + sort_order=sort_order, ) return classes_with_fa diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 48a94a2d..107faaf9 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -36,6 +36,8 @@ class ClassService: skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> tuple[List[Class], int]: """ Get all classes for a tenant with pagination and filters @@ -68,6 +70,18 @@ class ClassService: query = query.filter( Class.physical_review == filters["physical_review"] ) + + # Apply sorting + if sort_by: + column = getattr(Class, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting + query = query.order_by(Class.class_code.asc()) total = query.count() items = query.offset(skip).limit(limit).all() @@ -82,6 +96,8 @@ class ClassService: skip: int = 0, limit: int = 1000, filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> tuple[List[Dict[str, Any]], int]: """ Get all classes with their FA data in a single query using LEFT JOIN. @@ -125,6 +141,23 @@ class ClassService: if filters.get("fraction"): query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%")) + # Apply sorting + if sort_by: + # Check if sort_by belongs to Class or QClasses + column = getattr(Class, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Handle FA extension fields if sort_by is one of them + # (Simple approach for now, assuming base class fields are prioritized) + pass + else: + # Default sorting + query = query.order_by(Class.class_code.asc()) + # Count total before pagination total = query.count() diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index 14276427..f710a83d 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -38,6 +38,8 @@ class ClientProviderService: skip: int = 0, limit: int = 50, filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> Tuple[List[ClientProvider], int]: """Get all clients/providers for a tenant with pagination""" query = db.query(ClientProvider).filter(ClientProvider.tenant_id == tenant_id) @@ -68,6 +70,17 @@ class ClientProviderService: enabled = 1 if filters["status"] == "enabled" else 0 query = query.filter(ClientProvider.is_active == enabled) + # Apply sorting + if sort_by: + column = getattr(ClientProvider, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + query = query.order_by(ClientProvider.id.desc()) + total = query.count() clients = ( query.options( diff --git a/backend/api/v1/modules/a76/common/sorting.py b/backend/api/v1/modules/a76/common/sorting.py new file mode 100644 index 00000000..519cf888 --- /dev/null +++ b/backend/api/v1/modules/a76/common/sorting.py @@ -0,0 +1,61 @@ +from typing import Any, Optional, Type +from sqlalchemy import asc, desc, inspect +from sqlalchemy.orm import Query, RelationshipProperty + +def apply_sorting( + query: Query, + model: Type[Any], + sort_by: Optional[str] = None, + sort_desc: bool = True, + default_sort_col: str = "created_at" +) -> Query: + """ + Applies dynamic sorting to a SQLAlchemy query. + Supports nested attributes via dot notation (e.g., 'compliance_mx.remesa'). + Automatically handles joins if necessary. + """ + if not sort_by: + if hasattr(model, default_sort_col): + col = getattr(model, default_sort_col) + return query.order_by(desc(col)) + return query + + try: + parts = sort_by.split('.') + current_model = model + + # Traverse relationships if dot notation is used + for i, part in enumerate(parts[:-1]): + # Check if relationship exists + mapper = inspect(current_model) + if part in mapper.relationships: + rel = mapper.relationships[part] + # Join the relationship + query = query.join(rel.entity.class_) + current_model = rel.entity.class_ + else: + # If part is not a relationship, we can't go deeper + # Fallback to default sorting + if hasattr(model, default_sort_col): + return query.order_by(desc(getattr(model, default_sort_col))) + return query + + # The last part is the actual column + last_part = parts[-1] + if hasattr(current_model, last_part): + col = getattr(current_model, last_part) + if sort_desc: + query = query.order_by(desc(col)) + else: + query = query.order_by(asc(col)) + else: + # Fallback to default sorting on the original model + if hasattr(model, default_sort_col): + query = query.order_by(desc(getattr(model, default_sort_col))) + except Exception as e: + # Log error or handle gracefully + print(f"Error applying sort for {sort_by}: {e}") + if hasattr(model, default_sort_col): + query = query.order_by(desc(getattr(model, default_sort_col))) + + return query diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index 01cb13e2..6e62e896 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -26,6 +26,8 @@ class CustomsBrokerService: skip: int = 0, limit: int = 100, filters: dict = None, + sort_by: str = None, + sort_order: str = "asc", ): """Get all customs brokers for a tenant/company with pagination""" query = db.query(models.CustomsBroker).filter( @@ -33,6 +35,23 @@ class CustomsBrokerService: models.CustomsBroker.company_id == company_id, ) + if filters: + # Implement filters if needed in the future + pass + + # Apply sorting + if sort_by: + column = getattr(models.CustomsBroker, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + query = query.order_by(models.CustomsBroker.id.desc()) + else: + query = query.order_by(models.CustomsBroker.id.desc()) + total = query.count() items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index fa6c888e..68b9c7a9 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing import Dict, Any, Optional from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource @@ -95,6 +95,8 @@ def list_invoices( invoice_type: str = Query(None, description="Filter by invoice type"), manifest_number: str = Query(None, description="Filter by manifest number"), pedimento: str = Query(None, description="Filter by pedimento"), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -119,7 +121,7 @@ def list_invoices( filters = {k: v for k, v in filters.items() if v is not None} items, total = services.InvoiceService.get_all( - db, tenant_id, company_id, skip=skip, limit=page_size, filters=filters + db, tenant_id, company_id, skip=skip, limit=page_size, filters=filters, sort_by=sort_by, sort_order=sort_order ) print(f"DEBUG: InvoiceService returned {len(items)} items, total={total}") diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index a9871ad6..f7706436 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -115,6 +115,8 @@ class InvoiceService: skip: int = 0, limit: int = 100, filters: Optional[dict] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> Tuple[List[models.InvoiceHeader], int]: """Get all invoices for a tenant/company with pagination and optional filters""" query = db.query(models.InvoiceHeader).filter( @@ -165,6 +167,20 @@ class InvoiceService: models.InvoiceComplianceMx.manifest_number.ilike(f"%{filters['manifest_number']}%") ) + # Apply sorting + if sort_by: + # Simple column mapping + # This can be improved to handle joins if needed + column = getattr(models.InvoiceHeader, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting if none provided + query = query.order_by(models.InvoiceHeader.id.desc()) + total = query.count() items = query.offset(skip).limit(limit).all() diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index 4446bc4c..37efc39d 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -53,27 +53,28 @@ def validate_create( if not line.class_id: errors.add_required_error(field=f"line[{line_number}].class_id") - if not line.quantity.quantity or line.quantity.quantity <= 0: + if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0: errors.add_required_error(field=f"line[{line_number}].quantity.quantity") # TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema # if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False: if fa_data and not fa_data.is_subitem: if ( - not line.financial.unit_cost_capture + not line.financial + or not line.financial.unit_cost_capture or line.financial.unit_cost_capture <= 0 ): errors.add_required_error( field=f"line[{line_number}].financial.unit_cost_capture" ) - if not line.quantity.net_weight or line.quantity.net_weight <= 0: + if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0: errors.add_required_error(field=f"line[{line_number}].quantity.net_weight") - if not line.customs.origin_country: + if not line.customs or not line.customs.origin_country: errors.add_required_error(field=f"line[{line_number}].customs.origin_country") - if not line.customs.fraction_type: + if not line.customs or not line.customs.fraction_type: errors.add_required_error(field=f"line[{line_number}].customs.fraction_type") # FA-specific validations @@ -142,6 +143,23 @@ def validate_create( validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): + # Ensure nested objects exist for calculations + from ...schemas import ( + LineFinancialCreate, + LineQuantityCreate, + LineCustomCreate, + LineDescriptionCreate, + ) + + if line.financial is None: + line.financial = LineFinancialCreate() + if line.quantity is None: + line.quantity = LineQuantityCreate() + if line.customs is None: + line.customs = LineCustomCreate() + if line.description is None: + line.description = LineDescriptionCreate() + # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py index 6f9ad8a1..bee0f84a 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/update.py +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -27,6 +27,23 @@ def validate_update( validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): + # Ensure nested objects exist for calculations/partial updates + from ...schemas import ( + LineFinancialCreate, + LineQuantityCreate, + LineCustomCreate, + LineDescriptionCreate, + ) + + if line.financial is None: + line.financial = LineFinancialCreate() + if line.quantity is None: + line.quantity = LineQuantityCreate() + if line.customs is None: + line.customs = LineCustomCreate() + if line.description is None: + line.description = LineDescriptionCreate() + # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) diff --git a/backend/api/v1/modules/a76/items/imports/validators/common.py b/backend/api/v1/modules/a76/items/imports/validators/common.py index ad62f477..f4c3b6ba 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/validators/common.py @@ -61,7 +61,7 @@ def validate_common( code="UNIT_OF_MEASURE_REQUIRED", ) - if not line.customs.fraction: + if not line.customs or not line.customs.fraction: if not line_item: if not class_.fraction: errors.add_error( @@ -73,7 +73,7 @@ def validate_common( else: fraction = class_.fraction else: - if not line.customs.fraction: + if not line.customs or not line.customs.fraction: if not class_.fraction: errors.add_error( field=f"line[{line_number}].customs.fraction", @@ -87,7 +87,7 @@ def validate_common( if line_item: fraction = line.customs.fraction - if not line.description.description_spanish and not class_.description_es: + if (not line.description or not line.description.description_spanish) and not class_.description_es: errors.add_error( field=f"line[{line_number}].description.description_spanish", message="La descripción en español es obligatoria para la clase especificada.", @@ -95,7 +95,7 @@ def validate_common( code="DESCRIPTION_SPANISH_REQUIRED", ) - if not line.description.description_english and not class_.description_en: + if (not line.description or not line.description.description_english) and not class_.description_en: errors.add_error( field=f"line[{line_number}].description.description_english", message="La descripción en inglés es obligatoria para la clase especificada.", @@ -103,7 +103,7 @@ def validate_common( code="DESCRIPTION_ENGLISH_REQUIRED", ) - if line.quantity.quantity and line.quantity.quantity <= 0: + if line.quantity and line.quantity.quantity and line.quantity.quantity <= 0: errors.add_error( field=f"line[{line_number}].quantity.quantity", message="La cantidad debe ser mayor a cero.", @@ -129,7 +129,7 @@ def validate_common( code="UNIT_OF_MEASURE_NOT_FOUND", ) - if line.quantity.package_id: + if line.quantity and line.quantity.package_id: package = ( db.query(func.count(Package.id)) .filter( @@ -161,7 +161,7 @@ def validate_common( code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO", ) else: - if line.quantity.package_quantity and (line.quantity.package_quantity > 0 and not line.quantity.package_id): + if line.quantity and line.quantity.package_quantity and (line.quantity.package_quantity > 0 and not line.quantity.package_id): errors.add_error( field=f"line[{line_number}].quantity.package_id", message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.", @@ -173,9 +173,9 @@ def validate_common( fraction_type = None sector = None if fraction: - fraction = line.customs.fraction if line.customs.fraction else fraction + fraction = (line.customs.fraction if line.customs.fraction else fraction) if line.customs else fraction - country = line.customs.origin_country + country = line.customs.origin_country if line.customs else None if line_item and line_item.customs: country = ( line_item.customs.origin_country @@ -183,7 +183,7 @@ def validate_common( else country ) - fraction_type = line.customs.fraction_type.upper() + fraction_type = (line.customs.fraction_type.upper() if line.customs.fraction_type else None) if line.customs else None if line_item and line_item.customs: fraction_type = ( line_item.customs.fraction_type @@ -191,7 +191,7 @@ def validate_common( else fraction_type ) - sector = line.customs.sector + sector = line.customs.sector if line.customs else None if line_item and line_item.customs: sector = line_item.customs.sector if line_item.customs.sector else sector @@ -210,7 +210,7 @@ def validate_common( code="ORIGIN_COUNTRY_NOT_FOUND", ) else: - if fraction_type.strip().upper() not in vars(FractionType).values(): + if fraction_type and fraction_type.strip().upper() not in vars(FractionType).values(): valid_types = [ v for k, v in vars(FractionType).items() @@ -225,15 +225,16 @@ def validate_common( code="FRACTION_TYPE_INVALID", value=fraction_type, ) - else: - if fraction_type.strip().upper() == FractionType.PROSEC and not sector: + elif fraction_type: + ft_upper = fraction_type.strip().upper() + if ft_upper == FractionType.PROSEC and not sector: errors.add_error( field=f"line[{line_number}].customs.sector", message="El sector es obligatorio cuando el tipo de fracción es 'PROSEC'.", solution=["Proporciona un sector valido."], code="SECTOR_REQUIRED_FOR_PROSEC", ) - elif fraction_type.strip().upper() != FractionType.PROSEC and sector: + elif ft_upper != FractionType.PROSEC and sector: errors.add_error( field=f"line[{line_number}].customs.sector", message="El sector solo es aplicable cuando el tipo de fracción es 'PROSEC'.", @@ -242,7 +243,7 @@ def validate_common( ], code="SECTOR_ONLY_FOR_PROSEC", ) - elif fraction_type.strip().upper() == FractionType.PROSEC and sector: + elif ft_upper == FractionType.PROSEC and sector: sector_db: Sector = ( db.query(Sector).filter( Sector.key == sector, diff --git a/backend/api/v1/modules/a76/items/imports/validators/create.py b/backend/api/v1/modules/a76/items/imports/validators/create.py index 7dc00b1c..d24969fa 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/validators/create.py @@ -52,27 +52,28 @@ def validate_create( if not line.class_id: errors.add_required_error(field=f"line[{line_number}].class_id") - if not line.quantity.quantity or line.quantity.quantity <= 0: + if not line.quantity or not line.quantity.quantity or line.quantity.quantity <= 0: errors.add_required_error(field=f"line[{line_number}].quantity.quantity") # TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema # if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False: if fa_data and not fa_data.is_subitem: if ( - not line.financial.unit_cost_capture + not line.financial + or not line.financial.unit_cost_capture or line.financial.unit_cost_capture <= 0 ): errors.add_required_error( field=f"line[{line_number}].financial.unit_cost_capture" ) - if not line.quantity.net_weight or line.quantity.net_weight <= 0: + if not line.quantity or not line.quantity.net_weight or line.quantity.net_weight <= 0: errors.add_required_error(field=f"line[{line_number}].quantity.net_weight") - if not line.customs.origin_country: + if not line.customs or not line.customs.origin_country: errors.add_required_error(field=f"line[{line_number}].customs.origin_country") - if not line.customs.fraction_type: + if not line.customs or not line.customs.fraction_type: errors.add_required_error(field=f"line[{line_number}].customs.fraction_type") # FA-specific validations @@ -126,6 +127,23 @@ def validate_create( validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): + # Ensure nested objects exist for calculations + from ...schemas import ( + LineFinancialCreate, + LineQuantityCreate, + LineCustomCreate, + LineDescriptionCreate, + ) + + if line.financial is None: + line.financial = LineFinancialCreate() + if line.quantity is None: + line.quantity = LineQuantityCreate() + if line.customs is None: + line.customs = LineCustomCreate() + if line.description is None: + line.description = LineDescriptionCreate() + # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) diff --git a/backend/api/v1/modules/a76/items/imports/validators/update.py b/backend/api/v1/modules/a76/items/imports/validators/update.py index c5bd5e7a..93152823 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/validators/update.py @@ -26,6 +26,23 @@ def validate_update( validate_common(db, line, tenant_id, company_id, errors, line_number) if not errors.has_errors(): + # Ensure nested objects exist for calculations/partial updates + from ...schemas import ( + LineFinancialCreate, + LineQuantityCreate, + LineCustomCreate, + LineDescriptionCreate, + ) + + if line.financial is None: + line.financial = LineFinancialCreate() + if line.quantity is None: + line.quantity = LineQuantityCreate() + if line.customs is None: + line.customs = LineCustomCreate() + if line.description is None: + line.description = LineDescriptionCreate() + # Obtener la factura para acceder a tipo de cambio, moneda y peso invoice: InvoiceHeader = ( db.query(InvoiceHeader) diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index 65f559bf..4e9a83e4 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -83,6 +83,8 @@ async def list_items( None, description="Filter by system origin (SCAF/SCAII)"), search: Optional[str] = Query( None, description="Search term for invoice number, reference, order, or guide"), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -107,7 +109,8 @@ async def list_items( } items, total = service.get_all( - db, tenant_id, company_id, skip, limit, filters) + db, tenant_id, company_id, skip, limit, filters, sort_by=sort_by, sort_order=sort_order + ) return LineItemListResponse( total=total, @@ -168,23 +171,26 @@ async def delete_item( # ADDITIONAL ENDPOINTS FOR INVOICE # ============================================================================ -@router.get("/invoice/{invoice_id}/items", response_model=LineItemListResponse) -async def get_items_by_invoice( +@router.get("/invoice/{invoice_id}/items/", response_model=LineItemListResponse) +async def list_items_by_invoice( invoice_id: int = Path(..., description="Invoice ID"), company_id: int = Query(..., description="Company ID"), - skip: int = Query(0, ge=0), - limit: int = Query(100, ge=1, le=1000), + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"), + sort_by: Optional[str] = Query(None, description="Column to sort by"), + sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ - Get all items for a specific invoice + List all items for a specific invoice """ tenant_id = validate_access_to_resource(db, company_id, current_user) service = ItemService() items, total = service.get_by_invoice( - db, invoice_id, tenant_id, company_id, skip, limit) + db, invoice_id, tenant_id, company_id, skip, limit, sort_by=sort_by, sort_order=sort_order + ) return LineItemListResponse( total=total, diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 411c8393..5b6f9477 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -278,6 +278,8 @@ class ItemService: skip: int = 0, limit: int = 100, filters: Optional[dict] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> Tuple[List[LineItem], int]: """Get all items for a tenant/company with pagination and optional filters""" query = ( @@ -316,6 +318,25 @@ class ItemService: LineItem.guide_number.ilike(search_term), ) ) + if filters.get("invoice_number"): + # Join with InvoiceHeader to search by invoice_number + query = query.join(InvoiceHeader).filter( + InvoiceHeader.invoice_number.ilike(f"%{filters['invoice_number']}%") + ) + + # Apply sorting + if sort_by: + # Map sort_by to actual model column if possible + # Note: Some columns might require joins if they are in related models + column = getattr(LineItem, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting + query = query.order_by(LineItem.line_number.asc()) total = query.count() items = query.offset(skip).limit(limit).all() @@ -332,6 +353,8 @@ class ItemService: company_id: int, skip: int = 0, limit: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> Tuple[List[LineItem], int]: """Get all items for a specific invoice""" query = ( @@ -351,6 +374,18 @@ class ItemService: ) ) + # Apply sorting + if sort_by: + column = getattr(LineItem, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting + query = query.order_by(LineItem.line_number.asc()) + total = query.count() items = query.offset(skip).limit(limit).all() for item in items: @@ -494,8 +529,13 @@ class ItemService: ) except Exception as e: db.rollback() - logger.error(f"Unexpected error creating LineItem: {e}") - raise HTTPException(status_code=500, detail="Error creating LineItem") + import traceback + error_msg = f"Unexpected error creating LineItem: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + raise HTTPException( + status_code=500, + detail=f"Error interno al crear la partida: {type(e).__name__}: {str(e)}" + ) @staticmethod def update( diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index 334f7ffd..ecde9597 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -55,6 +55,8 @@ class PartService: skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> tuple[List[Part], int]: query = db.query(Part).filter(Part.tenant_id == tenant_id) @@ -82,6 +84,18 @@ class PartService: ) ) # Otros filtros... + + # Apply sorting + if sort_by: + column = getattr(Part, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting: Newest first + query = query.order_by(Part.id.desc()) total = query.count() items = query.offset(skip).limit(limit).all() diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index e95c99da..09a607df 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -69,6 +69,8 @@ class PedimentosService: skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = "asc", ) -> tuple[List[Pedimentos], int]: """ Get all pedimentos for a tenant with pagination and filters @@ -126,12 +128,22 @@ class PedimentosService: selectinload(Pedimentos.pedimento_seals), selectinload(Pedimentos.pedimento_containers), ) - .order_by(desc(Pedimentos.created_at)) - .offset(skip) - .limit(limit) - .all() ) + # Apply sorting + if sort_by: + column = getattr(Pedimentos, sort_by, None) + if column: + if sort_order == "desc": + query = query.order_by(column.desc()) + else: + query = query.order_by(column.asc()) + else: + # Default sorting + query = query.order_by(desc(Pedimentos.created_at)) + + items = query.offset(skip).limit(limit).all() + return items, total @staticmethod diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index de2a8ca5..00c1cc4a 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -59,6 +59,8 @@ export interface A76ClassListParams { class_code?: string; description?: string; q?: string; // Agregado por si usas búsqueda general + sort_by?: string; + sort_order?: 'asc' | 'desc'; } // --- API OBJECT --- @@ -112,12 +114,17 @@ export const classesApi = { company_id: number; page?: number; page_size?: number; + sort_by?: string; + sort_order?: 'asc' | 'desc'; }): Promise> => { const query = new URLSearchParams({ company_id: params.company_id.toString(), page: (params.page || 1).toString(), page_size: (params.page_size || 1000).toString() }); + + if (params.sort_by) query.append('sort_by', params.sort_by); + if (params.sort_order) query.append('sort_order', params.sort_order); return api.get(`/v1/a76/classes/with-fa-data?${query.toString()}`); } }; \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index ece9c57b..a0ef2c4f 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -154,7 +154,9 @@ export const partsApi = { company_id: number; page?: number; page_size?: number; - q?: string + q?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; }) => { const { company_id, page = 1, page_size = 50, q = '' } = params; @@ -168,6 +170,9 @@ export const partsApi = { queryParams.description = q; } + if (params.sort_by) queryParams.sort_by = params.sort_by; + if (params.sort_order) queryParams.sort_order = params.sort_order; + const query = new URLSearchParams(queryParams); return api.get(`/v1/a76/parts/?${query.toString()}`); diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index 8b9d46f6..dd876a08 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -274,6 +274,8 @@ export interface PedimentoFilters { status?: string; client_id?: number; year?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc' | string; } /** @@ -288,19 +290,21 @@ export const pedimentosApi = { * @param companyId - ID de la compañía (por defecto 1) */ list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId?: number) => { - let url = `/v1/a76/pedimentos/?company_id=${companyId}&page=${page}&page_size=${pageSize}`; + const params = new URLSearchParams({ + company_id: companyId?.toString() || '', + page: page.toString(), + page_size: pageSize.toString() + }); - if (filters?.status) { - url += `&status=${encodeURIComponent(filters.status)}`; - } - if (filters?.client_id) { - url += `&client_id=${filters.client_id}`; - } - if (filters?.year) { - url += `&year=${encodeURIComponent(filters.year)}`; + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.append(key, value.toString()); + } + }); } - return api.get(url); + return api.get(`/v1/a76/pedimentos/?${params.toString()}`); }, /** diff --git a/frontend/src/lib/components/dashboard/exchange_rate/columns.ts b/frontend/src/lib/components/dashboard/exchange_rate/columns.ts index 00dfe0db..2d6a6097 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/columns.ts +++ b/frontend/src/lib/components/dashboard/exchange_rate/columns.ts @@ -21,7 +21,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] cell: ({ row }) => { const value = row.original.value; if (value === null || value === undefined) return 'N/A'; - return value.toFixed(6); + return Number(value).toFixed(6); } }, { diff --git a/frontend/src/lib/components/dashboard/general_catalogs/customs_broker_concepts/columns.ts b/frontend/src/lib/components/dashboard/general_catalogs/customs_broker_concepts/columns.ts index a373b3e8..0f004d62 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/customs_broker_concepts/columns.ts +++ b/frontend/src/lib/components/dashboard/general_catalogs/customs_broker_concepts/columns.ts @@ -18,7 +18,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef row.original.amount ? `$${row.original.amount.toFixed(2)}` : '-' + cell: ({ row }) => row.original.amount ? `$${Number(row.original.amount).toFixed(2)}` : '-' }, { accessorKey: 'priority', diff --git a/frontend/src/lib/components/dashboard/goods/classes/columns.ts b/frontend/src/lib/components/dashboard/goods/classes/columns.ts new file mode 100644 index 00000000..e0f43946 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/classes/columns.ts @@ -0,0 +1,139 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import { renderSnippet } from "$lib/components/ui/data-table/index.js"; +import { createRawSnippet } from "svelte"; +import type { A76Class } from "$lib/api/dashboard/a76/classes"; + +export function createColumns(): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => { + return renderSnippet( + createRawSnippet(() => ({ + render: () => `
` + })) + ); + }, + cell: ({ row }) => { + const isSelected = row.getIsSelected(); + const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => { + const { selected } = getProps(); + return { + render: () => `
+ +
` + }; + }); + return renderSnippet(checkboxSnippet, { selected: isSelected }); + }, + size: 40, + enableSorting: false, + enableHiding: false + }, + { + accessorKey: "class_code", + header: "Clase", + enableSorting: true, + cell: ({ row }) => { + const codeSnippet = createRawSnippet<[{ code: string }]>((getProps) => { + const { code } = getProps(); + return { + render: () => `${code}` + }; + }); + return renderSnippet(codeSnippet, { code: row.original.class_code }); + } + }, + { + accessorKey: "description_es", + header: "Descripción Español", + enableSorting: true, + cell: ({ row }) => { + const descSnippet = createRawSnippet<[{ desc: string | null }]>((getProps) => { + const { desc } = getProps(); + return { + render: () => `
${desc || ''}
` + }; + }); + return renderSnippet(descSnippet, { desc: row.original.description_es }); + } + }, + { + accessorKey: "description_en", + header: "Descripción Inglés", + enableSorting: true, + cell: ({ row }) => { + const descSnippet = createRawSnippet<[{ desc: string | null }]>((getProps) => { + const { desc } = getProps(); + return { + render: () => `
${desc || ''}
` + }; + }); + return renderSnippet(descSnippet, { desc: row.original.description_en }); + } + }, + { + accessorKey: "material_key", + header: "Tipo", + enableSorting: true, + cell: ({ row }) => { + const typeSnippet = createRawSnippet<[{ key: string | null }]>((getProps) => { + const { key } = getProps(); + let colorClass = 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400'; + if (key === 'MP') colorClass = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; + else if (key === 'SC') colorClass = 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'; + else if (key === 'DESP') colorClass = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'; + + return { + render: () => `${key || ''}` + }; + }); + return renderSnippet(typeSnippet, { key: row.original.material_key }); + } + }, + { + accessorKey: "unit_of_measure", + header: "U.M.", + enableSorting: true, + cell: ({ row }) => { + const umSnippet = createRawSnippet<[{ um: string | null }]>((getProps) => { + const { um } = getProps(); + return { + render: () => `
${um || ''}
` + }; + }); + return renderSnippet(umSnippet, { um: row.original.unit_of_measure }); + } + }, + { + accessorKey: "fraction", + header: "Fracción", + enableSorting: true, + cell: ({ row }) => { + const fracSnippet = createRawSnippet<[{ fr: string | null }]>((getProps) => { + const { fr } = getProps(); + return { + render: () => `${fr || ''}` + }; + }); + return renderSnippet(fracSnippet, { fr: row.original.fraction }); + } + }, + { + accessorKey: "us_fraction", + header: "Fracción US", + enableSorting: true, + cell: ({ row }) => { + const fracSnippet = createRawSnippet<[{ fr: string | null }]>((getProps) => { + const { fr } = getProps(); + return { + render: () => `${fr || '-'}` + }; + }); + return renderSnippet(fracSnippet, { fr: row.original.us_fraction }); + } + } + ]; +} + +export const columns = createColumns(); diff --git a/frontend/src/lib/components/dashboard/goods/classes/data-table.svelte b/frontend/src/lib/components/dashboard/goods/classes/data-table.svelte new file mode 100644 index 00000000..47d5f7eb --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/classes/data-table.svelte @@ -0,0 +1,164 @@ + + +
+
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + { + if (onRowClick) { + onRowClick(row.original); + } + }} + class="cursor-pointer hover:bg-muted/50 transition-colors {row.getIsSelected() ? 'bg-primary/10' : ''}" + > + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + {#if loading} +
+
+

Cargando...

+
+ {:else} + No hay resultados. + {/if} +
+
+ {/each} +
+
+
+
diff --git a/frontend/src/lib/components/dashboard/goods/parts/columns.ts b/frontend/src/lib/components/dashboard/goods/parts/columns.ts index 1f161298..02a3b42f 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/columns.ts +++ b/frontend/src/lib/components/dashboard/goods/parts/columns.ts @@ -3,6 +3,7 @@ import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/in import { createRawSnippet } from "svelte"; import DataTableActions from "./data-table-actions.svelte"; import type { Part } from "$lib/api/dashboard/a76/parts"; +import { Checkbox } from "$lib/components/ui/checkbox/index.js"; /** * Formatea moneda (USD/MXN) @@ -18,10 +19,47 @@ function formatCurrency(amount: number | null, currency: string | null): string export function createColumns(onSuccess?: () => void): ColumnDef[] { return [ + { + id: "select", + header: ({ table }) => { + const headerCheckbox = createRawSnippet<[{ table: import("@tanstack/table-core").Table }]>((getTable) => { + const { table: t } = getTable(); + return { + render: () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = t.getIsAllPageRowsSelected(); + input.indeterminate = t.getIsSomePageRowsSelected(); + input.onchange = (e) => t.toggleAllPageRowsSelected(!!(e.target as HTMLInputElement).checked); + // Usamos el componente Checkbox si es posible, pero para snippets crudos en TanStack 5 + // a veces es más directo un input o un Snippet de Svelte. + // Aquí usaremos renderComponent para el Checkbox real. + return ""; + } + }; + }); + return renderComponent(Checkbox, { + checked: table.getIsAllPageRowsSelected(), + indeterminate: table.getIsSomePageRowsSelected(), + onCheckedChange: (value) => table.toggleAllPageRowsSelected(!!value), + "aria-label": "Select all" + }); + }, + cell: ({ row }) => { + return renderComponent(Checkbox, { + checked: row.getIsSelected(), + onCheckedChange: (value) => row.toggleSelected(!!value), + "aria-label": "Select row" + }); + }, + enableSorting: false, + enableHiding: false + }, { accessorKey: "is_active", header: "Status", + enableSorting: true, cell: ({ row }) => { const statusSnippet = createRawSnippet<[{ active: boolean }]>((getStatus) => { const { active } = getStatus(); @@ -39,6 +77,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "part_number", header: "No. Parte", + enableSorting: true, cell: ({ row }) => { const pnSnippet = createRawSnippet<[{ pn: string }]>((getPn) => { const { pn } = getPn(); @@ -55,6 +94,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "description_spanish", header: "Descripción", + enableSorting: true, cell: ({ row }) => { const descSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => { const { desc } = getDesc(); @@ -86,6 +126,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "part_class", header: "Clase", + enableSorting: true, cell: ({ row }) => { const classSnippet = createRawSnippet<[{ cls: string }]>((getCls) => { const { cls } = getCls(); @@ -117,12 +158,12 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { accessorKey: "unit_of_measure", header: "U.M.", cell: ({ row }) => { - const umSnippet = createRawSnippet<[{ um: string }]>((getUm) => { + const umSnippet = createRawSnippet<[{ um: string | null }]>((getUm) => { const { um } = getUm(); return { render: () => ` - ${um} + ${um || '-'} ` }; }); @@ -134,6 +175,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "fraction", header: "Fracción", + enableSorting: true, cell: ({ row }) => { const fracSnippet = createRawSnippet<[{ fr: string }]>((getFrac) => { const { fr } = getFrac(); @@ -164,6 +206,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "unit_cost", header: "Costo", + enableSorting: true, cell: ({ row }) => { const costSnippet = createRawSnippet<[{ amount: number | null, curr: string | null }]>((getCost) => { const { amount, curr } = getCost(); @@ -197,13 +240,14 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "unit_weight", header: "Peso Unitario", + enableSorting: true, cell: ({ row }) => { const weightSnippet = createRawSnippet<[{ weight: number | null, type: string | null }]>((getWeight) => { const { weight, type } = getWeight(); return { render: () => { if (weight === null || weight === undefined) return '-'; - return `
${weight.toFixed(4)} ${type || ''}
`; + return `
${Number(weight).toFixed(4)} ${type || ''}
`; } }; }); @@ -233,6 +277,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { { accessorKey: "updated_at", header: "Fecha Modificación", + enableSorting: true, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); diff --git a/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte b/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte index c5229943..0feda248 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte @@ -82,9 +82,9 @@ Acciones - + - Editar + Editar diff --git a/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte b/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte index 4c448e3d..2154fe75 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/data-table.svelte @@ -13,6 +13,11 @@ loading: boolean; hasMore: boolean; loadMore: () => void; + sorting?: import("@tanstack/table-core").SortingState; + onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void; + rowSelection?: import("@tanstack/table-core").RowSelectionState; + onRowSelectionChange?: (rowSelection: import("@tanstack/table-core").RowSelectionState) => void; + onRowClick?: (row: TData) => void; }; let { @@ -20,7 +25,12 @@ columns, loading, hasMore, - loadMore + loadMore, + sorting = [], + onSortingChange, + rowSelection = {}, + onRowSelectionChange, + onRowClick }: DataTableProps = $props(); const table = createSvelteTable({ @@ -28,7 +38,28 @@ return data; }, columns, - getCoreRowModel: getCoreRowModel() + getCoreRowModel: getCoreRowModel(), + state: { + get sorting() { + return sorting; + }, + get rowSelection() { + return rowSelection; + } + }, + onSortingChange: (updater) => { + if (onSortingChange) { + const nextSorting = typeof updater === 'function' ? updater(sorting) : updater; + onSortingChange(nextSorting); + } + }, + onRowSelectionChange: (updater) => { + if (onRowSelectionChange) { + const nextRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; + onRowSelectionChange(nextRowSelection); + } + }, + manualSorting: true }); let scrollContainer = $state(); @@ -66,12 +97,65 @@ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} - + {#if !header.isPlaceholder} - + {/if} {/each} @@ -80,7 +164,11 @@ {#each table.getRowModel().rows as row (row.id)} - + onRowClick?.(row.original)} + class={onRowClick ? "cursor-pointer" : ""} + > {#each row.getVisibleCells() as cell (cell.id)} { const operationType = row.original.operation_type; @@ -142,6 +143,7 @@ export function createColumns( { accessorKey: "invoice_type", header: "Tipo Factura", + enableSorting: true, cell: ({ row }) => { const invoiceType = row.original.invoice_type; const colorClass = getInvoiceTypeColor(invoiceType); @@ -161,6 +163,7 @@ export function createColumns( { accessorKey: "invoice_number", header: "Núm. Factura", + enableSorting: true, cell: ({ row }) => { const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getNumber) => { const { number } = getNumber(); @@ -210,6 +213,7 @@ export function createColumns( { accessorKey: "invoice_date", header: "Fecha Factura", + enableSorting: true, cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); @@ -240,6 +244,7 @@ export function createColumns( { accessorKey: "document_type", header: "Tipo Doc.", + enableSorting: true, cell: ({ row }) => { const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => { const { type } = getType(); diff --git a/frontend/src/lib/components/dashboard/invoices/data-table.svelte b/frontend/src/lib/components/dashboard/invoices/data-table.svelte index 29b7fb10..a5e37186 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table.svelte @@ -14,6 +14,8 @@ selectedIds?: number[]; onRowClick?: (row: TData) => void; compact?: boolean; + sorting?: import("@tanstack/table-core").SortingState; + onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void; }; let { @@ -24,7 +26,9 @@ loadMore, selectedIds = [], onRowClick, - compact = false + compact = false, + sorting = [], + onSortingChange }: DataTableProps = $props(); const table = createSvelteTable({ @@ -42,12 +46,24 @@ selection[id.toString()] = true; }); return selection; + }, + get sorting() { + return sorting; + } + }, + onStateChange: (updater: any) => { + if (onSortingChange) { + const nextSorting = typeof updater === 'function' ? updater(sorting) : updater; + if (nextSorting.sorting !== undefined) { + onSortingChange(nextSorting.sorting); + } else { + onSortingChange(nextSorting); + } } }, enableRowSelection: true, - enableMultiRowSelection: true - - // No necesitamos onRowSelectionChange porque controlamos el estado desde fuera + enableMultiRowSelection: true, + manualSorting: true // Sorting is handled server-side for this component }); let scrollContainer = $state(); @@ -99,10 +115,67 @@ .join(' ')} > {#if !header.isPlaceholder} - + {/if} {/each} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 23968e7a..6afc5f7a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -264,7 +264,7 @@
- {#if line} + {#if editingItem} {#if showRepairBlock}
@@ -481,13 +481,15 @@

Datos Principales

- + {#if editingItem.quantity && editingItem.financial && editingItem.customs} + + {/if}
@@ -498,10 +500,12 @@

Configuración

- + {#if editingItem.description} + + {/if}
@@ -519,38 +523,46 @@
- - + {#if editingItem.description && editingItem.customs && editingItem.quantity} + + {/if} + {#if editingItem.financial && editingItem.quantity} + + {/if}
- + {#if editingItem.description} + + {/if} - + {#if editingItem.description && editingItem.series} + + {/if} {#if visibility.showLabelingTab} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index 06daa656..99d52d99 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -139,16 +139,13 @@ }) ); - // Derived state for easier binding and safety - let line = $derived(editingItem); - - // Initialize missing nested objects if they don't exist + // Use editingItem directly and ensure it's safe $effect(() => { if (open && editingItem) { - if (editingItem && !editingItem.quantity) editingItem.quantity = {} as any; - if (editingItem && !editingItem.financial) editingItem.financial = {} as any; - if (editingItem && !editingItem.customs) editingItem.customs = {} as any; - if (editingItem && !editingItem.description) editingItem.description = {} as any; + if (!editingItem.quantity) editingItem.quantity = {} as any; + if (!editingItem.financial) editingItem.financial = {} as any; + if (!editingItem.customs) editingItem.customs = {} as any; + if (!editingItem.description) editingItem.description = {} as any; if (editingItem.has_fda_code === undefined) editingItem.has_fda_code = false; } }); @@ -193,7 +190,8 @@
-
+ {#if editingItem} +
General @@ -262,8 +260,8 @@
- {#if line?.quantity} - + {#if editingItem?.quantity} + {/if}
@@ -271,7 +269,7 @@
- {#if line?.financial} - + {#if editingItem?.financial} + {/if}
@@ -294,7 +292,7 @@
- + {#if editingItem?.customs} + + {/if}
@@ -363,11 +363,11 @@
- {#if line?.description} + {#if editingItem?.description} {/if}
@@ -375,12 +375,12 @@
- {#if line} + {#if editingItem}
(showPartDialog = true)} @@ -393,8 +393,8 @@
- {#if line?.description} - + {#if editingItem?.description} + {/if}
@@ -405,11 +405,11 @@
- {#if line?.customs} + {#if editingItem?.customs} {/if}
@@ -417,21 +417,21 @@
- {#if line?.description} + {#if editingItem?.description} {/if}
@@ -642,5 +642,11 @@
+ {:else} +
+ +

Cargando datos de la partida...

+
+ {/if} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 49b741da..9cd4aa80 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -45,21 +45,31 @@ // 1. Core State let items = $state([]); - let displayedItems = $state([]); let imported = $state(0); let net_weight = $state(0); let gross_weight = $state(0); let itemsPerPage = 20; let currentPage = $state(1); + let tableContainer = $state(); + let isLoadingMore = $state(false); + let isLoadingItems = $state(false); + let isSaving = $state(false); - // 2. Preset State + // 2. Sorting State + let sortField = $state(null); + let sortOrder = $state<'asc' | 'desc'>('asc'); + + // 3. Selection & Filtering State + let selectedLineIds = $state([]); + let focusedLine = $state(null); + + // 4. Preset State let presets = $state([]); let selectedPreset = $state(null); let searchPresets = $state(''); let isLoadingPresets = $state(false); let isApplyingPreset = $state(false); let showUsePresetDialog = $state(false); - // Preset Creation State let showCreatePresetDialog = $state(false); let createPresetName = $state(''); let createPresetDescription = $state(''); @@ -67,15 +77,31 @@ let isSavingPreset = $state(false); let isTargetingPreset = $state(false); let editingBuilderIndex = $state(null); + let builderDraft = $state({ + description: '', + quantity: 1, + unit_cost_usd: 0, + reference_number: '' + }); - // 3. Selection & Filtering State - let selectedLineIds = $state([]); - let focusedLine = $state(null); + // 5. Form/Sheet State + let showItemSheet = $state(false); + let isEditMode = $state(false); + let showDeleteDialog = $state(false); + let selectedItem = $state(null); + let originalItemData = $state | null>(null); + let editingItem = $state>({ + invoice_id: undefined, + reference_number: '', + order: '', + warehouse: '', + location: '' + }); - // 4. Derived Values (Ordered correctly to avoid TDZ) + // 6. Derived Values const flattenedLines = $derived.by(() => { const sourceItems = items?.length ? items : formData?.items || []; - return (sourceItems || []).map((item: any, itemIndex: number) => ({ + let lines = (sourceItems || []).map((item: any, itemIndex: number) => ({ ...item, id: item?.id || `item-${itemIndex}`, line_number: item?.line_number ?? itemIndex + 1, @@ -93,87 +119,90 @@ warehouse: item?.warehouse, full_item: item })); + + if (sortField) { + lines.sort((a: any, b: any) => { + let valA: any = a[sortField!]; + let valB: any = b[sortField!]; + + // Special handling for numeric and nested fields + if (sortField === 'quantity') { + valA = a.quantity?.quantity || 0; + valB = b.quantity?.quantity || 0; + } else if (sortField === 'unit_cost') { + valA = a.financial?.unit_cost_usd || a.financial?.unit_cost_capture || 0; + valB = b.financial?.unit_cost_usd || b.financial?.unit_cost_capture || 0; + } else if (sortField === 'value') { + valA = (a.quantity?.quantity || 0) * (a.financial?.unit_cost_usd || 0); + valB = (b.quantity?.quantity || 0) * (b.financial?.unit_cost_usd || 0); + } else if (sortField === 'line_number') { + valA = Number(a.line_number) || 0; + valB = Number(b.line_number) || 0; + } + + // Standard comparison + if (valA === undefined || valA === null) valA = ''; + if (valB === undefined || valB === null) valB = ''; + + if (typeof valA === 'string') valA = valA.toLowerCase(); + if (typeof valB === 'string') valB = valB.toLowerCase(); + + if (valA < valB) return sortOrder === 'asc' ? -1 : 1; + if (valA > valB) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + } + + return lines; }); + const displayedItems = $derived(flattenedLines.slice(0, currentPage * itemsPerPage)); + const isAllSelected = $derived( flattenedLines.length > 0 && selectedLineIds.length === flattenedLines.length ); + const filteredPresets = $derived( + presets.filter( + (p) => + p.name.toLowerCase().includes(searchPresets.toLowerCase()) || + p.description?.toLowerCase().includes(searchPresets.toLowerCase()) + ) + ); + + const selectedPresetItems = $derived(selectedPreset?.items || []); + const selectedPresetCount = $derived(selectedPreset?.items?.length || 0); + const invoiceSystem = $derived(invoice?.system || 'scaii'); const itemVisibility = $derived.by(() => getVisibility(invoiceType, operationType)); const showCrTrackingHeader = $derived(itemVisibility.showCrTrackingHeader); - /** Debe coincidir con el orden de celdas en cada rama del tbody (exportación ≠ importación genérica ≠ REP). */ const emptyStateColspan = $derived.by(() => { if (operationType === 1) return 11; if (showCrTrackingHeader) return 12; if (invoiceType === 'REP' || invoiceType === 'REPAR') return 13; return 10; }); - const invoiceLabel = $derived.by(() => { - if (invoice?.invoice_number) return `Factura ${invoice.invoice_number}`; - if (invoice?.id) return `Factura ${invoice.id}`; - return 'Factura nueva'; - }); - const filteredPresets = $derived.by(() => { - const term = searchPresets.trim().toLowerCase(); - if (!term) return presets; - return presets.filter( - (p: ItemPreset) => - p.name.toLowerCase().includes(term) || p.description?.toLowerCase().includes(term) - ); - }); - - const selectedPresetItems = $derived(selectedPreset?.items || []); - const selectedPresetCount = $derived(selectedPresetItems.length); const activeCompanyId = $derived(companyStore?.activeCompany?.id); const formItemsCount = $derived(formData?.items?.length || 0); - // 5. UI State - let tableContainer = $state(); - let isLoadingMore = $state(false); - let isLoadingItems = $state(false); - let isSaving = $state(false); - - // 6. Form/Sheet State - let showItemSheet = $state(false); - let isEditMode = $state(false); - let showDeleteDialog = $state(false); - let selectedItem = $state(null); - let originalItemData = $state | null>(null); - let editingItem = $state>({ - invoice_id: undefined, - reference_number: '', - order: '', - warehouse: '', - location: '' - }); - let builderDraft = $state({ - description: '', - quantity: 1, - unit_cost_usd: 0, - reference_number: '' - }); - // 7. Effects - $effect(() => { - currentPage = 1; - displayedItems = flattenedLines.slice(0, itemsPerPage); - }); - $effect(() => { if (invoice?.id && activeCompanyId) { loadItems(); } }); - $effect(() => { - if (invoice?.id && activeCompanyId && formItemsCount > 0) { - loadItems(); + // 8. Handlers & Functions + function toggleSort(field: string) { + if (sortField === field) { + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + sortField = field; + sortOrder = 'asc'; } - }); + } - // 8. Functions function toggleSelectAll() { if (isAllSelected) { selectedLineIds = []; @@ -199,27 +228,22 @@ if (response.data) { items = response.data.items || []; currentPage = 1; - // Wait for derived state to update before loading items - await new Promise((resolve) => setTimeout(resolve, 0)); - loadMoreItems(); + updateTotals(); } } catch (error: any) { console.error('Error loading items:', error); - const errorMessage = - error?.response?.data?.detail || 'No se pudieron cargar los items de la factura.'; toast.error('Error al cargar items', { - description: errorMessage + description: error?.response?.data?.detail || 'No se pudieron cargar los items.' }); } finally { isLoadingItems = false; } } - function loadMoreItems() { - const start = 0; - const end = currentPage * itemsPerPage; - displayedItems = flattenedLines.slice(start, end); - isLoadingMore = false; + function updateTotals() { + imported = items.reduce((sum, item: any) => sum + (Number(item.quantity?.quantity) || 0), 0); + net_weight = items.reduce((sum, item: any) => sum + (Number(item.quantity?.net_weight || (item as any).net_weight) || 0), 0); + gross_weight = items.reduce((sum, item: any) => sum + (Number(item.quantity?.gross_weight || (item as any).gross_weight) || 0), 0); } function handleRowClick(line: any) { @@ -235,16 +259,49 @@ if (scrolledToBottom && !isLoadingMore && displayedItems.length < flattenedLines.length) { isLoadingMore = true; currentPage++; - loadMoreItems(); + isLoadingMore = false; // Simple logic for local pagination } } + function normalizeItemData(item: Partial): Partial { + if (!item) return {}; + const normalizedItem = { ...item }; + + if (normalizedItem.financial) { + normalizedItem.financial = { + ...normalizedItem.financial, + unit_cost_usd: normalizedItem.financial.unit_cost_usd != null ? Number(normalizedItem.financial.unit_cost_usd) : undefined, + unit_cost_mxn: normalizedItem.financial.unit_cost_mxn != null ? Number(normalizedItem.financial.unit_cost_mxn) : undefined, + value_usd: normalizedItem.financial.value_usd != null ? Number(normalizedItem.financial.value_usd) : undefined, + value_mxn: normalizedItem.financial.value_mxn != null ? Number(normalizedItem.financial.value_mxn) : undefined + }; + } else { + normalizedItem.financial = { unit_cost_usd: undefined }; + } + + if (normalizedItem.quantity) { + normalizedItem.quantity = { + ...normalizedItem.quantity, + quantity: normalizedItem.quantity.quantity != null ? Number(normalizedItem.quantity.quantity) : undefined, + net_weight: normalizedItem.quantity.net_weight != null ? Number(normalizedItem.quantity.net_weight) : undefined, + gross_weight: normalizedItem.quantity.gross_weight != null ? Number(normalizedItem.quantity.gross_weight) : undefined, + package_quantity: normalizedItem.quantity.package_quantity != null ? Number(normalizedItem.quantity.package_quantity) : undefined + }; + } else { + normalizedItem.quantity = { quantity: undefined }; + } + + if (!normalizedItem.customs) normalizedItem.customs = { origin_country: undefined, fraction_type: undefined }; + if (!normalizedItem.description) normalizedItem.description = { description_spanish: undefined }; + if (!normalizedItem.fa_data) normalizedItem.fa_data = {}; + + return normalizedItem; + } + function handleAdd() { - // Validar que la factura esté guardada (tiene ID) si no estamos en modo plantilla if (!showCreatePresetDialog && !invoice?.id) { toast.warning('Factura no guardada', { - description: 'Debes guardar la factura primero antes de agregar partidas.', - duration: 5000 + description: 'Debes guardar la factura primero antes de agregar partidas.' }); return; } @@ -252,777 +309,212 @@ isEditMode = false; isTargetingPreset = showCreatePresetDialog; editingBuilderIndex = null; - showItemSheet = true; - // Auto-asignar valores desde la factura con estructura completa editingItem = { invoice_id: invoice?.id, - line_number: 1, - // LineItem fields - part_number: undefined, - component_part_number: undefined, - class_id: undefined, - identifier: undefined, - unit_of_measure: undefined, - alternate_unit: undefined, - permit_number: undefined, - page_line: undefined, - has_fda_code: false, - fda_key: undefined, - fcc_key: undefined, - has_certificate: false, - certificate_number: undefined, - tax_payment: false, - payment_method: undefined, - igi_payment_method: undefined, - igi_amount: undefined, - is_military_mcia: false, - wildcard_field: undefined, + line_number: (items.length || 0) + 1, reference_number: '', order: invoice?.purchase_order || '', - warehouse: '', - location: '', - // Nested relations - financial: { - unit_cost_usd: undefined, - unit_cost_mxn: undefined, - unit_cost_capture: undefined, - unit_cost_commercial_usd: undefined, - value_usd: undefined, - value_mxn: undefined, - value_returned_usd: undefined, - value_returned_mxn: undefined, - customs_value_usd: undefined - }, - quantity: { - quantity: undefined, - unit_of_measure: undefined, - quantity_temp_export: undefined, - net_weight: undefined, - gross_weight: undefined, - package_id: undefined, - package_quantity: undefined, - package_description: undefined - }, - customs: { - fraction: undefined, - fraction_type: undefined, - american_fraction: undefined, - origin_country: undefined, - destination_country: undefined, - advalorem: undefined, - advalorem_american: undefined, - sector: undefined - }, - description: { - description_spanish: undefined, - description_english: undefined, - extra_description: undefined, - additional_info_spanish: undefined, - brand: undefined, - model: undefined, - has_serial: false, - eighth_rule_fraction: undefined, - eighth_rule_line: undefined, - consider_a31: false, - machinery_location: undefined - }, - reference: { - serie_id: undefined - }, - fa_data: { - search_invoice: undefined, - search_line: undefined, - search_type: undefined, - movement_type_import: undefined, - own_equipment: false, - omit_annex31: false - }, - series: [] + financial: {}, + quantity: {}, + customs: {}, + description: {}, + fa_data: {} }; + showItemSheet = true; } - async function loadPresets(force = false) { - if (!activeCompanyId) return; - if (!force && isLoadingPresets) return; - isLoadingPresets = true; + function handleEdit(lineData: any) { + isEditMode = true; + selectedItem = lineData.full_item; + editingItem = normalizeItemData(JSON.parse(JSON.stringify(lineData.full_item))); + if (!editingItem.series) editingItem.series = []; + originalItemData = JSON.parse(JSON.stringify(editingItem)); + showItemSheet = true; + } + + function handleDelete(lineData: any) { + selectedItem = lineData.full_item; + showDeleteDialog = true; + } + + async function confirmDelete() { + if (!selectedItem?.id || !activeCompanyId) return; + isSaving = true; try { - const response = await itemPresetsApi.list(activeCompanyId); - presets = response.data || []; - if (selectedPreset) { - selectedPreset = presets.find((p) => p.id === selectedPreset?.id) || null; - } + await itemsApi.delete(selectedItem.id, activeCompanyId); + toast.success('Partida eliminada'); + await loadItems(); + showDeleteDialog = false; } catch (error) { - console.error('Error loading presets:', error); - toast.error('No se pudieron cargar las plantillas'); + console.error('Error deleting item:', error); + toast.error('No se pudo eliminar la partida'); } finally { - isLoadingPresets = false; + isSaving = false; } } + async function saveItem() { + if (!editingItem) { + toast.warning('No hay datos para guardar'); + return; + } + + // Basic validation + if (!editingItem.class_id && !editingItem.description?.description_spanish) { + toast.warning('Completa los campos necesarios (Clase o Descripción)'); + return; + } + + if (isTargetingPreset) { + saveItemToPreset(); + return; + } + + if (!activeCompanyId) { + toast.warning('No hay ID de empresa activo. Asegúrate de tener una empresa seleccionada.'); + return; + } + + if (!invoice?.id) { + toast.warning('No hay ID de factura. La factura debe ser guardada antes de agregar partidas.'); + return; + } + isSaving = true; + try { + const payload = cleanLineData(editingItem); + + if (isEditMode && selectedItem?.id) { + await itemsApi.update(selectedItem.id, activeCompanyId, { ...payload, invoice_id: invoice.id }); + toast.success('Partida actualizada'); + } else { + const createData = { ...payload, invoice_id: invoice.id }; + await itemsApi.create(activeCompanyId, createData); + toast.success('Partida creada'); + } + await loadItems(); + showItemSheet = false; + } catch (error: any) { + const rawDetail = error?.response?.data?.detail ?? error?.response?.data ?? error?.message ?? String(error); + let errorMsg = 'No se pudo guardar la partida.'; + + if (Array.isArray(rawDetail)) { + errorMsg = rawDetail.map((e: any) => `[${e.loc?.join('.') ?? '?'}]: ${e.msg}`).join('\n'); + } else if (typeof rawDetail === 'string') { + errorMsg = rawDetail; + } else if (rawDetail && typeof rawDetail === 'object') { + errorMsg = JSON.stringify(rawDetail, null, 2); + } + + toast.error('Error al guardar', { + description: errorMsg.slice(0, 500), + duration: 10000 + }); + } finally { + isSaving = false; + } + } + + function saveItemToPreset() { + let cleanedItem = cleanLineData(editingItem); + if (editingBuilderIndex !== null) { + builderItems[editingBuilderIndex] = cleanedItem; + } else { + builderItems = [...builderItems, cleanedItem]; + } + showItemSheet = false; + isTargetingPreset = false; + editingBuilderIndex = null; + toast.success('Partida guardada en plantilla'); + } + function openUsePresetDialog() { if (!invoice?.id) { toast.warning('Primero guarda la factura para usar plantillas.'); return; } showUsePresetDialog = true; - if (!presets.length) void loadPresets(); + loadPresets(); + } + + async function loadPresets() { + if (!activeCompanyId) return; + isLoadingPresets = true; + try { + const response = await itemPresetsApi.list(activeCompanyId); + presets = response.data || []; + } catch (error) { + console.error('Error loading presets:', error); + } finally { + isLoadingPresets = false; + } + } + + async function applySelectedPreset() { + if (!selectedPreset || !invoice?.id || !activeCompanyId) return; + isApplyingPreset = true; + try { + for (const item of selectedPreset.items || []) { + const payload = { ...item, invoice_id: invoice.id, id: undefined }; + await itemsApi.create(activeCompanyId, payload); + } + await loadItems(); + toast.success('Plantilla aplicada'); + showUsePresetDialog = false; + } catch (error) { + console.error('Error applying preset:', error); + toast.error('Error al aplicar plantilla'); + } finally { + isApplyingPreset = false; + } } function openCreatePresetDialog() { builderItems = []; createPresetName = ''; createPresetDescription = ''; - isTargetingPreset = false; - editingBuilderIndex = null; showCreatePresetDialog = true; } function handleEditInPreset(index: number) { - const itemToEdit = builderItems[index]; - isEditMode = true; - isTargetingPreset = true; editingBuilderIndex = index; - editingItem = normalizeItemData(JSON.parse(JSON.stringify(itemToEdit))); + editingItem = normalizeItemData(JSON.parse(JSON.stringify(builderItems[index]))); + isTargetingPreset = true; + isEditMode = true; showItemSheet = true; } function handleRemoveFromPreset(index: number) { - builderItems = builderItems.filter((_: any, i: number) => i !== index); - } - - function saveItemToPreset() { - // Sanitizar datos para la plantilla - let cleanedItem = JSON.parse(JSON.stringify(editingItem)); - - // Limpiar item para asegurar que es compatible - if (cleanedItem) { - cleanedItem = { - ...cleanLineData(cleanedItem), - id: undefined // Las plantillas no deben tener IDs reales - }; - } - - if (editingBuilderIndex !== null) { - // Update existing item in builder - builderItems[editingBuilderIndex] = cleanedItem; - toast.success('Partida actualizada en la plantilla'); - } else { - // Add new item to builder - builderItems = [...builderItems, cleanedItem]; - toast.success('Partida agregada a la plantilla'); - } - - showItemSheet = false; - isTargetingPreset = false; - editingBuilderIndex = null; - } - - function sanitizeLineForPreset(line: any) { - const { id, item_id, created_at, updated_at, temp_id, ...rest } = line || {}; - return cleanLineData({ ...rest }); - } - - function cloneItemForPreset(item: InvoiceItem) { - const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; - return { - ...sanitizeLineForPreset(rest), - id: undefined, - invoice_id: undefined - }; - } - - function buildManualItem(draft: any, index: number) { - return cleanLineData({ - id: undefined, - temp_id: undefined, - invoice_id: undefined, - reference_number: draft.reference_number || undefined, - line_number: index + 1, - description: { - description_spanish: draft.description || 'Sin descripción' - }, - quantity: { - quantity: Number(draft.quantity) || 0 - }, - financial: { - unit_cost_usd: draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined - } - }); - } - - function handleAddManualItem() { - if (!builderDraft.description.trim()) { - toast.warning('Agrega una descripción para la partida'); - return; - } - const newItem = buildManualItem(builderDraft, builderItems.length); - builderItems = [...builderItems, newItem]; - builderDraft = { - description: '', - quantity: 1, - unit_cost_usd: 0, - reference_number: '' - }; - } - - async function applySelectedPreset() { - if (!selectedPreset) { - toast.warning('Selecciona una plantilla para aplicarla.'); - return; - } - if (!selectedPreset.items?.length) { - toast.warning('Esta plantilla no tiene partidas.'); - return; - } - - // Inject into active sheet if open - // No se pueden inyectar múltiples líneas en un item, ya que ahora un item ES una línea - if (showItemSheet) { - toast.warning('No se puede inyectar plantilla en modo edición', { - description: 'Las plantillas solo se pueden aplicar directamente a la factura' - }); - showUsePresetDialog = false; - return; - } - - if (!invoice?.id || !activeCompanyId) { - toast.warning('Primero guarda la factura para usar plantillas.'); - return; - } - - isApplyingPreset = true; - try { - const payloads = selectedPreset.items.map((item) => ({ - ...cloneItemForPreset(item), - invoice_id: invoice.id - })); - - await Promise.all(payloads.map((payload) => itemsApi.create(activeCompanyId, payload))); - await loadItems(); - toast.success('Plantilla aplicada a la factura'); - showUsePresetDialog = false; - } catch (error) { - console.error('Error applying preset:', error); - toast.error('No se pudo aplicar la plantilla'); - } finally { - isApplyingPreset = false; - } + builderItems = builderItems.filter((_, i) => i !== index); } async function saveCurrentItemsAsPreset() { - if (!createPresetName.trim()) { - toast.warning('Asigna un nombre a la plantilla'); - return; - } - if (!activeCompanyId) return; - - if (builderItems.length === 0) { - toast.warning('No hay partidas o líneas para guardar como plantilla'); - return; - } - + if (!createPresetName || !activeCompanyId) return; isSavingPreset = true; try { - // We group everything as items for the template - const lines = builderItems.map((item: InvoiceItem, idx: number) => { - return { - ...cleanLineData(item), - line_number: item.line_number || idx + 1, // Ensure line_number is present - id: undefined // Ensure no IDs are saved in the preset - }; - }); - - const payloadItems = [ - { - reference_number: builderItems[0]?.reference_number || undefined, - lines: lines - } - ] as any; - - await itemPresetsApi.create(activeCompanyId, { - name: createPresetName.trim(), - description: createPresetDescription.trim() || undefined, - items: payloadItems - }); - - createPresetName = ''; - createPresetDescription = ''; - builderItems = []; - selectedLineIds = []; - builderDraft = { description: '', quantity: 1, unit_cost_usd: 0, reference_number: '' }; - toast.success('Plantilla guardada correctamente'); - await loadPresets(true); + const payload = { + name: createPresetName, + description: createPresetDescription, + items: builderItems + }; + await itemPresetsApi.create(activeCompanyId, payload); + toast.success('Plantilla guardada'); showCreatePresetDialog = false; + loadPresets(); } catch (error) { console.error('Error saving preset:', error); - toast.error('No se pudo guardar la plantilla'); + toast.error('Error al guardar plantilla'); } finally { isSavingPreset = false; } } - function handleEdit(lineData: any) { - isEditMode = true; - selectedItem = lineData.full_item; - // Deep clone and normalize numeric values - editingItem = normalizeItemData(JSON.parse(JSON.stringify(lineData.full_item))); - if (!editingItem.series) editingItem.series = []; - else if (!Array.isArray(editingItem.series)) editingItem.series = [editingItem.series]; - // Guardar una copia del estado original para restaurar al cancelar - originalItemData = JSON.parse(JSON.stringify(editingItem)); - // Enrich with descriptive data - enrichItemData(editingItem); - showItemSheet = true; - } - - // Enrich item with descriptive data for display - async function enrichItemData(item: Partial) { - if (!item || !activeCompanyId) return; - - // Load class data - if (item.class_id) { - try { - const response = await fetch( - `/api-sveltekit/classes/${item.class_id}?company_id=${activeCompanyId}`, - { method: 'GET', headers: { 'Content-Type': 'application/json' } } - ); - if (response.ok) { - const classData = await response.json(); - (item as any).class_code = classData.class_code; - (item as any).class_unit_of_measure = classData.unit_of_measure; - (item as any).class_description = classData.description_es || classData.description_en; - } - } catch (error) { - console.error('Error loading class data:', error); - } - } - - // Load part number data - if (item.part_number_id) { - try { - const response = await fetch( - `/api-sveltekit/parts/${item.part_number_id}?company_id=${activeCompanyId}`, - { method: 'GET', headers: { 'Content-Type': 'application/json' } } - ); - if (response.ok) { - const partData = await response.json(); - (item as any).part_number_display = partData.part_number; - (item as any).part_description_es = partData.description_spanish; - (item as any).part_description_en = partData.description_english; - } - } catch (error) { - console.error('Error loading part data:', error); - } - } - - // Load unit of measure data - if (item.unit_of_measure) { - try { - const response = await fetch(`/api-sveltekit/units-of-measure/${item.unit_of_measure}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - }); - if (response.ok) { - const unitData = await response.json(); - (item as any).unit_code = unitData.code; - (item as any).unit_description = unitData.description || unitData.description_en; - } - } catch (error) { - console.error('Error loading unit data:', error); - } - } - - // Load country data (if needed) - if (item.customs?.origin_country) { - try { - const response = await fetch( - `/api-sveltekit/countries?search=${item.customs.origin_country}`, - { method: 'GET', headers: { 'Content-Type': 'application/json' } } - ); - if (response.ok) { - const data = await response.json(); - if (data.items && data.items.length > 0) { - const country = data.items[0]; - (item.customs as any).origin_country_name = - country.description || country.description_en; - } - } - } catch (error) { - console.error('Error loading country data:', error); - } - } - - // Load fraction data (if needed) - if (item.customs?.fraction) { - try { - const response = await fetch( - `/api-sveltekit/tariff-fractions?search=${item.customs.fraction}`, - { method: 'GET', headers: { 'Content-Type': 'application/json' } } - ); - if (response.ok) { - const data = await response.json(); - if (data.items && data.items.length > 0) { - const fraction = data.items[0]; - (item.customs as any).fraction_description = fraction.description; - } - } - } catch (error) { - console.error('Error loading fraction data:', error); - } - } - - // Load package data (if needed) - const packageId = item.quantity?.package_id; - if (packageId && item.quantity) { - try { - const response = await fetch('/api-sveltekit/packages', { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - }); - if (response.ok) { - const data = await response.json(); - const packages = data.items || data.data || data; - if (Array.isArray(packages)) { - const pkg = packages.find((p: any) => p.id === packageId); - if (pkg) { - (item.quantity as any).package_description = - pkg.description_es || pkg.description_en || pkg.key; - (item.quantity as any).package_key = pkg.key; - (item.quantity as any).package_weight_unit = pkg.weight_unit || 0; - } - } - } - } catch (error) { - console.error('Error loading package data:', error); - } - } - - // Load payment method description (if needed) - if (item.payment_method) { - try { - const response = await fetch('/api-sveltekit/payment-methods', { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - }); - if (response.ok) { - const data = await response.json(); - const methods = data.items || data.data || data; - if (Array.isArray(methods)) { - const method = methods.find((m: any) => m.key === item.payment_method); - if (method) { - (item as any).payment_method_description = method.description; - } - } - } - } catch (error) { - console.error('Error loading payment method data:', error); - } - } - } - - // Normalize numeric values from strings to numbers - function normalizeItemData(item: Partial): Partial { - if (item) { - const normalizedItem = { ...item }; - - // Normalize financials - if (normalizedItem.financial) { - normalizedItem.financial = { - ...normalizedItem.financial, - unit_cost_usd: - normalizedItem.financial.unit_cost_usd != null - ? Number(normalizedItem.financial.unit_cost_usd) - : undefined, - unit_cost_mxn: - normalizedItem.financial.unit_cost_mxn != null - ? Number(normalizedItem.financial.unit_cost_mxn) - : undefined, - value_usd: - normalizedItem.financial.value_usd != null - ? Number(normalizedItem.financial.value_usd) - : undefined, - value_mxn: - normalizedItem.financial.value_mxn != null - ? Number(normalizedItem.financial.value_mxn) - : undefined - }; - } - - // Normalize quantities - if (normalizedItem.quantity) { - normalizedItem.quantity = { - ...normalizedItem.quantity, - quantity: - normalizedItem.quantity.quantity != null - ? Number(normalizedItem.quantity.quantity) - : undefined, - net_weight: - normalizedItem.quantity.net_weight != null - ? Number(normalizedItem.quantity.net_weight) - : undefined, - gross_weight: - normalizedItem.quantity.gross_weight != null - ? Number(normalizedItem.quantity.gross_weight) - : undefined, - package_quantity: - normalizedItem.quantity.package_quantity != null - ? Number(normalizedItem.quantity.package_quantity) - : undefined - }; - } - - return normalizedItem; - } - - return item; - } - - function handleDelete(lineData: any) { - selectedItem = lineData.full_item; - showDeleteDialog = true; - } - - async function saveNewItem() { - if (!invoice?.id || !activeCompanyId) return; - - isSaving = true; - try { - // Clean item data before sending - const cleanedItem = cleanLineData(editingItem); - - const response = await itemsApi.create(activeCompanyId, { - ...cleanedItem, - invoice_id: invoice.id - }); - - // Verificar si hay errores de validación - if ('error' in response) { - // Manejar errores de validación (422) - if (response.validationErrors && Array.isArray(response.validationErrors)) { - const validationErrors = response.validationErrors - .map((err: any) => `• ${err.message}`) - .join('\n'); - - toast.error('Errores de validación', { - description: validationErrors, - duration: 10000 - }); - } else { - toast.error('Error al crear item', { - description: response.error || 'No se pudo crear el item. Intenta de nuevo.' - }); - } - isSaving = false; - return; - } - - // Recargar items - await loadItems(); - - showItemSheet = false; - toast.success('Item creado', { - description: 'El item se ha creado correctamente.' - }); - } catch (error: any) { - console.error('Error creating item:', error); - - // Manejar errores de validación (422) - if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) { - const validationErrors = error.response.data.errors - .map((err: any) => `• ${err.message}`) - .join('\n'); - - toast.error('Errores de validación', { - description: validationErrors - }); - } else { - const errorMessage = - error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.'; - toast.error('Error al crear item', { - description: errorMessage - }); - } - } finally { - isSaving = false; - } - } - - async function saveEditedItem() { - if (!selectedItem?.id || !activeCompanyId) return; - - isSaving = true; - try { - // Clean item data before sending - const cleanedItem = cleanLineData(editingItem); - - const response = await itemsApi.update(selectedItem.id, activeCompanyId, cleanedItem); - - // Verificar si hay errores de validación - if ('error' in response) { - // Manejar errores de validación (422) - if (response.validationErrors && Array.isArray(response.validationErrors)) { - const validationErrors = response.validationErrors - .map((err: any) => `• ${err.message}`) - .join('\n'); - - toast.error('Errores de validación', { - description: validationErrors, - duration: 10000 - }); - } else { - toast.error('Error al actualizar item', { - description: response.error || 'No se pudo actualizar el item. Intenta de nuevo.' - }); - } - isSaving = false; - return; - } - - // Recargar items - await loadItems(); - - showItemSheet = false; - toast.success('Item actualizado', { - description: 'El item se ha actualizado correctamente.' - }); - } catch (error: any) { - console.error('Error updating item:', error); - - // Manejar errores de validación (422) - if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) { - const validationErrors = error.response.data.errors - .map((err: any) => `• ${err.message}`) - .join('\n'); - - toast.error('Errores de validación', { - description: validationErrors - }); - } else { - const errorMessage = - error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.'; - toast.error('Error al actualizar item', { - description: errorMessage - }); - } - } finally { - isSaving = false; - } - } - - function saveItem() { - // Validar campos obligatorios antes de guardar - const missingFields: string[] = []; - - if (!editingItem) { - toast.warning('Error de datos', { - description: 'No se encontró información del item' - }); - return; - } - - // 1. Clase - if (!editingItem.class_id) { - missingFields.push('Clase'); - } - - // 2. Cantidad - if (!editingItem.quantity?.quantity || editingItem.quantity.quantity <= 0) { - missingFields.push('Cantidad'); - } - - // 3. Unidad de Medida - if (!editingItem.unit_of_measure) { - missingFields.push('U.M. (Unidad de Medida)'); - } - - // 4. Costo Unitario (al menos uno debe estar presente) - const hasCost = - editingItem.financial?.unit_cost_usd || - editingItem.financial?.unit_cost_mxn || - editingItem.financial?.unit_cost_capture; - if (!hasCost) { - missingFields.push('Costo Unitario (USD, MXN o Captura)'); - } - - // 5. País de Origen - if (!editingItem.customs?.origin_country) { - missingFields.push('País de Origen'); - } - - // 6. Tipo de Tarifa - if (!editingItem.customs?.fraction_type) { - missingFields.push('Tipo de Tarifa'); - } - - // 7. Descripción en Español - if (!editingItem.description?.description_spanish?.trim()) { - missingFields.push('Descripción en Español'); - } - - if (missingFields.length > 0) { - const fieldsList = missingFields.join('\n• '); - toast.warning('Completa los campos obligatorios', { - description: `Faltan los siguientes campos:\n• ${fieldsList}`, - duration: 10000 - }); - return; - } - - // Si pasa la validación, continuar con el guardado - if (isTargetingPreset) { - saveItemToPreset(); - } else if (isEditMode) { - saveEditedItem(); - } else { - saveNewItem(); - } - } - - async function confirmDelete() { - if (!selectedItem?.id || !activeCompanyId) return; - - isSaving = true; - try { - await itemsApi.delete(selectedItem.id, activeCompanyId); - - // Recargar items - await loadItems(); - - showDeleteDialog = false; - toast.success('Item eliminado', { - description: 'El item se ha eliminado correctamente.' - }); - } catch (error: any) { - console.error('Error deleting item:', error); - const errorMessage = - error?.response?.data?.detail || 'No se pudo eliminar el item. Intenta de nuevo.'; - toast.error('Error al eliminar item', { - description: errorMessage - }); - } finally { - isSaving = false; - } - } - - function handleCancelEdit() { - // Restaurar los datos originales si estamos editando - if (isEditMode && originalItemData) { - editingItem = JSON.parse(JSON.stringify(originalItemData)); - } - // Cerrar el sheet - showItemSheet = false; - } - - /** Sticky sin bordes extra (evitan desalinear thead/tbody en tablas auto-layout). */ - const STICKY_LINE_HEAD = - 'sticky left-0 z-40 bg-background shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[3px_0_8px_-4px_rgba(0,0,0,0.35)]'; - const STICKY_ACTIONS_HEAD = - 'sticky right-0 z-40 w-[104px] min-w-[104px] bg-background text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.35)]'; + const STICKY_LINE_HEAD = 'sticky left-0 z-40 bg-background shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)]'; + const STICKY_ACTIONS_HEAD = 'sticky right-0 z-40 w-[104px] min-w-[104px] bg-background text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)]'; function stickyLineCellClass(itemId: string) { const focused = focusedLine?.id === itemId; return [ - 'sticky left-0 z-30 shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[3px_0_8px_-4px_rgba(0,0,0,0.35)]', + 'sticky left-0 z-30 shadow-[3px_0_8px_-4px_rgba(0,0,0,0.12)]', focused ? 'bg-muted' : 'bg-background group-hover/item-row:bg-muted/50' ].join(' '); } @@ -1030,7 +522,7 @@ function stickyActionsCellClass(itemId: string) { const focused = focusedLine?.id === itemId; return [ - 'sticky right-0 z-30 w-[104px] min-w-[104px] text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)] dark:shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.35)]', + 'sticky right-0 z-30 w-[104px] min-w-[104px] text-right shadow-[-3px_0_8px_-4px_rgba(0,0,0,0.12)]', focused ? 'bg-muted' : 'bg-background group-hover/item-row:bg-muted/50' ].join(' '); } @@ -1075,18 +567,65 @@ - Línea + toggleSort('line_number')} + > +
+ Línea + {#if sortField === 'line_number'} +
+ {#if sortOrder === 'asc'} + + {:else} + + {/if} +
+ {/if} +
+
{#if operationType === 1} Factura Impo Línea P/S Cant. Importada Clase - Número Parte - Descripción + toggleSort('part_number_display')} + > +
+ Número Parte + {#if sortField === 'part_number_display'} +
+ {#if sortOrder === 'asc'} + + {:else} + + {/if} +
+ {/if} +
+
+ toggleSort('class_description')} + > +
+ Descripción + {#if sortField === 'class_description'} +
+ {#if sortOrder === 'asc'} + + {:else} + + {/if} +
+ {/if} +
+
Contiene Subpartida Partida Principal - Acciones {:else if showCrTrackingHeader} Factura Impo Línea @@ -1098,31 +637,117 @@ Preferencia Contiene Subpartida Partida Principal - Acciones {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} Factura Impo Línea P/S Clase - Número Parte + toggleSort('part_number_display')} + > +
+ Número Parte + {#if sortField === 'part_number_display'} +
+ {#if sortOrder === 'asc'} + + {:else} + + {/if} +
+ {/if} +
+
Descripcion Clase Cant. Importada U.M. Preferencia Contiene Subpartida Partida Principal - Acciones {:else} P/S - Clase - Descripcion Clase - Cant. Importada - U.M. - Preferencia + toggleSort('class_code')} + > +
+ Clase + {#if sortField === 'class_code'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
+ toggleSort('class_description')} + > +
+ Descripcion Clase + {#if sortField === 'class_description'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
+ toggleSort('quantity')} + > +
+ Cantidad + {#if sortField === 'quantity'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
+ toggleSort('unit_of_measure_code')} + > +
+ U.M. + {#if sortField === 'unit_of_measure_code'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
+ toggleSort('reference_number')} + > +
+ Preferencia + {#if sortField === 'reference_number'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
Contiene Subpartida - Partida Principal - Acciones + toggleSort('warehouse')} + > +
+ Partida Principal + {#if sortField === 'warehouse'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{/if} + Acciones
@@ -1365,7 +990,7 @@ {/if} {/each} diff --git a/frontend/src/lib/utils/items-logic.ts b/frontend/src/lib/utils/items-logic.ts index aa95881b..de44d0e4 100644 --- a/frontend/src/lib/utils/items-logic.ts +++ b/frontend/src/lib/utils/items-logic.ts @@ -7,7 +7,13 @@ export interface Item { // Helper function to check if an object has any meaningful values export function hasValues(obj: any): boolean { if (!obj || typeof obj !== 'object') return false; - return Object.values(obj).some( + + // Si el objeto está intencionalmente vacío o tiene campos que serán usados, + // es mejor dejar que el backend valide si es requerido. + const values = Object.values(obj); + if (values.length === 0) return false; + + return values.some( (val) => val !== undefined && val !== null && @@ -18,7 +24,9 @@ export function hasValues(obj: any): boolean { // Clean nested data before sending to API export function cleanLineData(line: any) { - const cleaned: any = { ...line }; + // 1. First, deeply copy and unwrap any Svelte Proxies to ensure a clean JS object + const rawLine = JSON.parse(JSON.stringify(line)); + const cleaned: any = { ...rawLine }; // Helper function to convert to number or undefined const toNumberOrUndefined = (value: any): number | undefined => { @@ -29,47 +37,88 @@ export function cleanLineData(line: any) { return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined; }; - // Convert integer fields - cleaned.part_number = toNumberOrUndefined(cleaned.part_number); + // Explicitly keep mandatory fields for a76 schema + cleaned.invoice_id = toNumberOrUndefined(rawLine.invoice_id); + cleaned.line_number = toNumberOrUndefined(rawLine.line_number); + + // Reconstruction approach for core objects to be 100% sure + // We ALWAYS want these objects to exist in the payload even if all fields are null + // so the backend validation layer doesn't crash (AttributeError on None) + + cleaned.financial = { + unit_cost_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_usd) : undefined, + unit_cost_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_mxn) : undefined, + unit_cost_capture: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_capture) : undefined, + value_mc: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mc) : undefined, + value_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_usd) : undefined, + value_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mxn) : undefined + }; + + cleaned.quantity = { + quantity: rawLine.quantity ? (toNumberOrUndefined(rawLine.quantity.quantity) || 0) : 0, + net_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.net_weight) : undefined, + gross_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.gross_weight) : undefined, + package_id: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_id) : undefined, + package_quantity: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_quantity) : undefined + // NOTE: unit_of_measure lives at the top-level LineItem, NOT inside quantity + }; + + cleaned.description = { + description_spanish: rawLine.description ? (rawLine.description.description_spanish || '') : '', + description_english: rawLine.description ? (rawLine.description.description_english || '') : '', + brand: rawLine.description ? (rawLine.description.brand || '') : '', + model: rawLine.description ? (rawLine.description.model || '') : '' + }; + + cleaned.customs = { + fraction: rawLine.customs ? (rawLine.customs.fraction || undefined) : undefined, + american_fraction: rawLine.customs ? (rawLine.customs.american_fraction || undefined) : undefined, + origin_country: rawLine.customs ? (rawLine.customs.origin_country || undefined) : undefined, + fraction_type: rawLine.customs ? (rawLine.customs.fraction_type || undefined) : undefined + }; + + // Convert integer fields (only if they look like numbers/IDs) + if (cleaned.part_number && !isNaN(Number(cleaned.part_number))) { + cleaned.part_number = toNumberOrUndefined(cleaned.part_number); + } + cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number); cleaned.class_id = toNumberOrUndefined(cleaned.class_id); + + // Ensure part_number is preserved if it's a string (common in some modules) + if (rawLine.part_number && !cleaned.part_number) { + cleaned.part_number = rawLine.part_number; + } + cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure); cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit); - // Remove display-only fields + // Remove ALL display-only and UI-specific fields that the backend schema doesn't know about delete cleaned.class_code; delete cleaned.class_unit_of_measure; delete cleaned.class_description; - - // UI specific fields that shouldn't be in the payload delete cleaned.part_description_es; delete cleaned.part_description_en; + delete cleaned.part_number_display; // UI display field for part number string delete cleaned.unit_code; delete cleaned.unit_description; delete cleaned.includes_subitems; delete cleaned.payment_method_description; - - // Only delete part_number if it's the string code from UI, but schema expects int ID. - // In this codebase, if part_number is populated from existing data, it's an ID. - // If it's a new item, it might be cleaned. + // Remove any other unknown top-level display fields + delete (cleaned as any).class_unit_of_measure_description; // Remove display-only fields from nested objects if (cleaned.customs) { delete cleaned.customs.origin_country_name; delete cleaned.customs.fraction_description; - if (!hasValues(cleaned.customs)) delete cleaned.customs; + // DO NOT delete if empty - backend needs the object structure } if (cleaned.fa_data) { delete cleaned.fa_data.includes_subitems; - if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data; + // Keep fa_data as is otherwise (unwrapped by stringify/parse above) } - // Remove empty nested objects - if (cleaned.financial && !hasValues(cleaned.financial)) delete cleaned.financial; - if (cleaned.quantity && !hasValues(cleaned.quantity)) delete cleaned.quantity; - if (cleaned.description && !hasValues(cleaned.description)) delete cleaned.description; - if (cleaned.reference && !hasValues(cleaned.reference)) delete cleaned.reference; if (cleaned.series != null) { const arr = Array.isArray(cleaned.series) ? cleaned.series : [cleaned.series]; cleaned.series = arr diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index 35ccccde..3b9bf873 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -10,6 +10,8 @@ import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes'; import { companyStore } from '$lib/stores/company.svelte'; import { onMount } from 'svelte'; + import DataTable from '$lib/components/dashboard/goods/classes/data-table.svelte'; + import { columns } from '$lib/components/dashboard/goods/classes/columns'; // Tipo extendido que combina A76Class y FAClass interface FixedAssetClassExtended extends A76Class { @@ -39,6 +41,7 @@ let showDeleteDialog = $state(false); let validationError = $state(''); let isSaving = $state(false); + let sorting = $state([]); // Estado del formulario let formData = $state({ @@ -56,32 +59,26 @@ bom: '' }); - // Clases filtradas según búsqueda + // Clases filtradas según búsqueda (mantenemos filtrado local para compatibilidad inmediata) const filteredClasses = $derived( classes.filter((c) => { - // Filtro por código de clase - const matchesCode = - !searchTerm || c.class_code.toLowerCase().includes(searchTerm.toLowerCase()); - - // Filtro por descripción (español o inglés) - const matchesDescription = - !searchDescription || + const matchesCode = !searchTerm || c.class_code.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesDescription = !searchDescription || (c.description_es?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) || (c.description_en?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false); - - // Filtro por tipo de material - const matchesType = - !searchType || (c.material_key?.toLowerCase().includes(searchType.toLowerCase()) ?? false); - - // Filtro por fracción arancelaria - const matchesFraction = - !searchFraction || - (c.fraction?.toLowerCase().includes(searchFraction.toLowerCase()) ?? false); - + const matchesType = !searchType || (c.material_key?.toLowerCase().includes(searchType.toLowerCase()) ?? false); + const matchesFraction = !searchFraction || (c.fraction?.toLowerCase().includes(searchFraction.toLowerCase()) ?? false); return matchesCode && matchesDescription && matchesType && matchesFraction; }) ); + // Efecto para reaccionar al cambio de ordenamiento + $effect(() => { + if (sorting.length >= 0) { + loadClasses(); + } + }); + // Reactively load classes when company changes $effect(() => { const companyId = companyStore.activeCompany?.id; @@ -102,7 +99,9 @@ const response = await classesApi.getWithFAData({ company_id: companyId, page: 1, - page_size: 1000 + page_size: 1000, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }); if (response.data) { @@ -295,82 +294,16 @@
-
- - - - - - - - - - - - - - - - {#if isLoading} - - - - {:else if filteredClasses.length === 0} - - - - {:else} - {#each filteredClasses as cls (cls.id)} - selectClass(cls)} - > - - - - - - - - - - {/each} - {/if} - -
- - ClaseDescripción EspañolDescripción InglésTipoU.MFracciónU.M.T.Fracción US
Cargando...
- No hay clases de activo fijo registradas -
- - - - {cls.class_code} - - {cls.description_es || ''}{cls.description_en || ''} - - {cls.material_key || ''} - - {cls.unit_of_measure || ''}{cls.fraction || ''} -{cls.us_fraction || '-'}
+
+ (sorting = newSorting)} + />
diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index 4499b065..0f126844 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -8,8 +8,10 @@ import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; - import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list'; import { goto } from '$app/navigation'; + import DataTable from '$lib/components/dashboard/goods/parts/data-table.svelte'; + import { createColumns } from '$lib/components/dashboard/goods/parts/columns'; + import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list'; // Estado de la lista de partes let parts = $state([]); @@ -20,45 +22,40 @@ let searchDescription = $state(''); let searchClient = $state(''); let searchClass = $state(''); - let systemFilter = $state<'ALL' | 'SCAI' | 'SCAF'>('ALL'); // SCAI = inv_data, SCAF = fa_data + let systemFilter = $state<'ALL' | 'SCAI' | 'SCAF'>('ALL'); + let sorting = $state([{ id: 'updated_at', desc: true }]); + const columns = createColumns(() => loadParts()); - // Partes filtradas según búsqueda + // Para mantener compatibilidad con el diseño original que usa filtros locales, + // pero ahora con soporte para ordenamiento en el servidor. const filteredParts = $derived( parts.filter((p) => { - // Filtro por número de parte const matchesPartNumber = !searchPartNumber || p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase()); - - // Filtro por descripción (español o inglés) const matchesDescription = !searchDescription || (p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) || (p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false); - - // Filtro por cliente (Busca en nombre o ID) const clientName = clientsMap[p.client_id] || ''; const matchesClient = !searchClient || (p.client_id?.toString().includes(searchClient) ?? false) || clientName.toLowerCase().includes(searchClient.toLowerCase()); - - // Filtro por clase const matchesClass = !searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false); - - // Filtro por sistema (SCAI/SCAF) let matchesSystem = true; - if (systemFilter === 'SCAI') { - matchesSystem = !!p.inv_data; - } else if (systemFilter === 'SCAF') { - matchesSystem = !!p.fa_data; - } - - return ( - matchesPartNumber && matchesDescription && matchesClient && matchesClass && matchesSystem - ); + if (systemFilter === 'SCAI') matchesSystem = !!p.inv_data; + else if (systemFilter === 'SCAF') matchesSystem = !!p.fa_data; + return matchesPartNumber && matchesDescription && matchesClient && matchesClass && matchesSystem; }) ); + + // Reaccionar al cambio de ordenamiento + $effect(() => { + if (sorting.length >= 0) { + loadParts(); + } + }); // Reactively load parts when company changes $effect(() => { const companyId = companyStore.activeCompany?.id; @@ -120,7 +117,9 @@ const response = await partsApi.list({ company_id: companyId, page: 1, - page_size: 1000 + page_size: 1000, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }); if (response.data) { @@ -283,106 +282,17 @@
-
- - - - - - - - - - - - - - - {#if isLoading} - - - - {:else if filteredParts.length === 0} - - - - {:else} - {#each filteredParts as part (part.id)} - selectPart(part)} - > - - - - - - - - - - {/each} - {/if} - -
- - Número de ParteDescripciónClienteClaseU.M.FracciónSistema
Cargando...
- No hay partes registradas -
- - - - {part.part_number} - - {part.description_spanish || ''} -
- {clientsMap[part.client_id] || 'Sin cliente'} -
-
- {#if part.part_class} - - {part.part_class} - - {:else} - - - {/if} - {part.unit_of_measure || '-'}{part.fraction || '-'} - {#if part.inv_data && part.fa_data} - - AMBOS - - {:else if part.inv_data} - - SCAI - - {:else if part.fa_data} - - SCAF - - {:else} - - {/if} -
+
+ {}} + {sorting} + onSortingChange={(newSorting) => (sorting = newSorting)} + onRowClick={selectPart} + />
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 320bb7c3..7823be87 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -67,6 +67,8 @@ project_number: data.filters?.project_number || '', year: data.filters?.year || '' }); + + let sorting = $state([]); let isDownloadModalOpen = $state(false); let isTransferenciaModalOpen = $state(false); @@ -143,7 +145,7 @@ }; const currentFiltersKey = JSON.stringify(currentFilters); - // Limpiar selección cada vez que se modifica algún filtro + // Limpiar selección cada vez que se modifica algún filtro o el orden if (currentFiltersKey !== lastFiltersKey) { selectedInvoiceIds = []; lastFiltersKey = currentFiltersKey; @@ -156,6 +158,14 @@ }, 300); // Esperar 300ms después del último cambio }); + // Efecto para reaccionar al cambio de ordenamiento + $effect(() => { + // Cuando cambia el sorting, aplicamos filtros (que reinicia a la página 1) + if (sorting.length >= 0) { + applyFilters(); + } + }); + // Sincronizar token de cookies a localStorage al montar el componente onMount(() => { if (browser) { @@ -248,7 +258,9 @@ invoice_type: filters.invoice_type || undefined, invoice_number: filters.invoice_number || undefined, project_number: filters.project_number || undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await invoicesApi.list(companyId, currentPage + 1, pageSize, filterParams); @@ -305,7 +317,9 @@ invoice_type: filters.invoice_type || undefined, invoice_number: filters.invoice_number || undefined, project_number: filters.project_number || undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await invoicesApi.list(companyId, 1, pageSize, filterParams); @@ -363,7 +377,9 @@ invoice_type: filters.invoice_type || undefined, invoice_number: filters.invoice_number || undefined, project_number: filters.project_number || undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await invoicesApi.list(companyId, 1, pageSize, filterParams); @@ -955,12 +971,14 @@ (sorting = newSorting)} /> diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index d8283960..34f59e23 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -31,6 +31,8 @@ year: '' }); + let sorting = $state([{ id: 'id', desc: true }]); + // Sincronizar token de cookies a localStorage al montar el componente onMount(() => { if (browser) { @@ -79,6 +81,14 @@ let pageSize = $state(50); let totalItems = $state(data.total || 0); let loading = $state(false); + let isSaving = $state(false); + + // Efecto para reaccionar al cambio de ordenamiento + $effect(() => { + if (sorting.length >= 0) { + applyFilters(); + } + }); let hasMore = $derived(allItems.length < totalItems); let error = $state(data.error || null); @@ -247,7 +257,9 @@ const filterParams = { status: filters.status || undefined, client_id: filters.client_id ? parseInt(filters.client_id) : undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await pedimentosApi.list(currentPage + 1, pageSize, filterParams, companyId); @@ -298,7 +310,9 @@ const filterParams = { status: filters.status || undefined, client_id: filters.client_id ? parseInt(filters.client_id) : undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await pedimentosApi.list(1, pageSize, filterParams, companyId); @@ -357,7 +371,9 @@ const filterParams = { status: filters.status || undefined, client_id: filters.client_id ? parseInt(filters.client_id) : undefined, - year: filters.year || undefined + year: filters.year || undefined, + sort_by: sorting.length > 0 ? sorting[0].id : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined }; const response = await pedimentosApi.list(1, pageSize, filterParams, companyId); @@ -521,6 +537,8 @@ {loadMore} {selectedId} onRowClick={handleRowClick} + {sorting} + onSortingChange={(newSorting) => (sorting = newSorting)} /> From dd4eec56f8849fad75bb871e50f93f1889cb6cc1 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 27 Mar 2026 09:15:58 -0500 Subject: [PATCH 2/3] Limpieza y correcion de warnings en ultimos cambios --- .../dashboard/goods/parts/columns.ts | 10 +- .../components/dashboard/invoices/columns.ts | 4 +- .../dashboard/invoices/data-table.svelte | 14 +- .../invoices/edit/items/fa/tab-series.svelte | 19 +- .../src/lib/components/help/HelpDrawer.svelte | 179 +++++++++--------- .../ui/data-table/data-table.svelte.ts | 6 +- 6 files changed, 121 insertions(+), 111 deletions(-) diff --git a/frontend/src/lib/components/dashboard/goods/parts/columns.ts b/frontend/src/lib/components/dashboard/goods/parts/columns.ts index 02a3b42f..b7b98ed7 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/columns.ts +++ b/frontend/src/lib/components/dashboard/goods/parts/columns.ts @@ -34,7 +34,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { // Usamos el componente Checkbox si es posible, pero para snippets crudos en TanStack 5 // a veces es más directo un input o un Snippet de Svelte. // Aquí usaremos renderComponent para el Checkbox real. - return ""; + return ""; } }; }); @@ -162,9 +162,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { const { um } = getUm(); return { render: () => - ` - ${um || '-'} - ` + `${um || '-'}` }; }); return renderSnippet(umSnippet, { um: row.original.unit_of_measure }); @@ -246,7 +244,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { const { weight, type } = getWeight(); return { render: () => { - if (weight === null || weight === undefined) return '-'; + if (weight === null || weight === undefined) return '-'; return `
${Number(weight).toFixed(4)} ${type || ''}
`; } }; @@ -281,7 +279,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { cell: ({ row }) => { const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => { const { date } = getDate(); - if (!date) return { render: () => '-' }; + if (!date) return { render: () => '-' }; const formatted = new Date(date).toLocaleDateString('es-MX', { year: 'numeric', month: '2-digit', diff --git a/frontend/src/lib/components/dashboard/invoices/columns.ts b/frontend/src/lib/components/dashboard/invoices/columns.ts index 8b19c99a..a035b954 100644 --- a/frontend/src/lib/components/dashboard/invoices/columns.ts +++ b/frontend/src/lib/components/dashboard/invoices/columns.ts @@ -152,9 +152,7 @@ export function createColumns( const { type, colorClass } = getProps(); return { render: () => - ` - ${type || '-'} - ` + `${type || '-'}` }; }); return renderSnippet(typeSnippet, { type: invoiceType, colorClass }); diff --git a/frontend/src/lib/components/dashboard/invoices/data-table.svelte b/frontend/src/lib/components/dashboard/invoices/data-table.svelte index a5e37186..870a2c38 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table.svelte @@ -53,11 +53,15 @@ }, onStateChange: (updater: any) => { if (onSortingChange) { - const nextSorting = typeof updater === 'function' ? updater(sorting) : updater; - if (nextSorting.sorting !== undefined) { - onSortingChange(nextSorting.sorting); - } else { - onSortingChange(nextSorting); + const currentState = table.getState(); + const nextState = typeof updater === 'function' ? updater(currentState) : updater; + + // Identify if this was a sorting update or at least contains sorting + if (nextState && nextState.sorting !== undefined) { + onSortingChange(nextState.sorting); + } else if (Array.isArray(nextState)) { + // Fallback for when updater might return just the array slice + onSortingChange(nextState); } } }, diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index f362937f..08671076 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -64,12 +64,14 @@ : null ); - // Ensure descriptions.has_serial has default - $effect(() => { - if (descriptions && descriptions.has_serial === undefined) { - descriptions.has_serial = false; + const internalHasSerial = $derived(descriptions?.has_serial === true); + function toggleHasSerial() { + if (descriptions) { + descriptions.has_serial = !descriptions.has_serial; } - }); + } + + const hasSerial = $derived(internalHasSerial); // Ensure current serie has defaults for form fields $effect(() => { @@ -83,7 +85,6 @@ } }); - const hasSerial = $derived(Boolean(descriptions?.has_serial)); const invoiceNumber = $derived(invoice?.invoice_number || ''); const invoiceLine = $derived(lineItem?.line_number != null ? String(lineItem.line_number) : ''); const partNumber = $derived((lineItem as any)?.part_number_display || lineItem?.part_number || ''); @@ -137,7 +138,11 @@
- + { if (descriptions) descriptions.has_serial = v; }} + />
- {/if} - Base de Conocimientos - - + + {#if selectedArticle} + + {/if} + Base de Conocimientos + + -
- {#if !selectedArticle} -
-
-

Artículos Disponibles

- {#if isAdmin} - +
+ {#if !selectedArticle} +
+
+

Artículos Disponibles

+ {#if isAdmin} + + {/if} +
+ {#if isLoading} +

Cargando...

+ {:else if articles.length === 0} +

+ No hay artículos de ayuda disponibles. +

+ {/if} +
+ {#each articles as article (article.uuid)} + + {/each} +
+
+ {:else} +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} +
+
+

{selectedArticle.title}

+ {#if isAdmin} + + {/if} +
+
+ {#if browser} + {@html renderMarkdown(selectedArticle.content)} + {:else} +
{selectedArticle.content}
+ {/if} +
+
{/if}
- {#if isLoading} -

Cargando...

- {:else if articles.length === 0} -

- No hay artículos de ayuda disponibles. -

- {/if} -
- {#each articles as article} - - {/each} -
-
- {:else} -
- {#if isEditing} -
- - -
- - -
-
- {:else} -
-
-

{selectedArticle.title}

- {#if isAdmin} - - {/if} -
-
- {@html renderMarkdown(selectedArticle.content)} -
-
- {/if} -
- {/if} -
+ {/if} +
diff --git a/frontend/src/lib/components/ui/data-table/data-table.svelte.ts b/frontend/src/lib/components/ui/data-table/data-table.svelte.ts index 5b7985e7..01f55af7 100644 --- a/frontend/src/lib/components/ui/data-table/data-table.svelte.ts +++ b/frontend/src/lib/components/ui/data-table/data-table.svelte.ts @@ -49,7 +49,8 @@ export function createSvelteTable(options: TableOptions>(table.initialState); + // Use JSON parse/stringify to ensure we get a clean, non-proxy initial state object + let state = $state>(JSON.parse(JSON.stringify(table.initialState))); function updateOptions() { table.setOptions((prev) => { @@ -105,8 +106,7 @@ export function mergeObjects[]>( return new Proxy(Object.create(null), { get(_, key) { const src = findSourceWithKey(key); - - return src?.[key as never]; + return src ? src[key as never] : undefined; }, has(_, key) { From 183a6f3348947a0d11bb6be256ea2ce7431175b5 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 27 Mar 2026 09:50:47 -0500 Subject: [PATCH 3/3] correcion de saldos temporales --- .../v1/modules/a76/reports/movements/saldos/csv_utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py index d82cb28b..791e4cc6 100644 --- a/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py +++ b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py @@ -262,7 +262,7 @@ BASE_JOINS = """ WHERE tenant_id = :tenant_id ORDER BY import_item_line_id, id DESC ) lm ON lm.import_item_line_id = il.id - LEFT JOIN ( + JOIN ( SELECT import_item_line_id, SUM( @@ -356,6 +356,7 @@ def _query_ped(filters: SaldosFilter) -> tuple: {BASE_JOINS} WHERE ih.tenant_id = :tenant_id AND ih.operation_type = 'imp' + AND ent.qty_impo > 0 {company_filter} {date_filter} {level_filter} @@ -380,6 +381,7 @@ def _query_fpp(filters: SaldosFilter) -> tuple: {BASE_JOINS} WHERE ih.tenant_id = :tenant_id AND ih.operation_type = 'imp' + AND ent.qty_impo > 0 {company_filter} {date_filter} {level_filter} @@ -403,6 +405,7 @@ def _query_ffa(filters: SaldosFilter) -> tuple: {BASE_JOINS} WHERE ih.tenant_id = :tenant_id AND ih.operation_type = 'imp' + AND ent.qty_impo > 0 {company_filter} {date_filter} {level_filter} @@ -434,6 +437,7 @@ def _query_par(filters: SaldosFilter) -> tuple: {BASE_JOINS} WHERE ih.tenant_id = :tenant_id AND ih.operation_type = 'imp' + AND ent.qty_impo > 0 {company_filter} {id_filter} {date_filter} @@ -466,6 +470,7 @@ def _query_cla(filters: SaldosFilter) -> tuple: {BASE_JOINS} WHERE ih.tenant_id = :tenant_id AND ih.operation_type = 'imp' + AND ent.qty_impo > 0 {company_filter} {id_filter} {date_filter}