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/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} 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..b7b98ed7 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,13 +158,11 @@ 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 || '-'}` }; }); return renderSnippet(umSnippet, { um: row.original.unit_of_measure }); @@ -134,6 +173,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 +204,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 +238,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 || ''}
`; + if (weight === null || weight === undefined) return '-'; + return `
${Number(weight).toFixed(4)} ${type || ''}
`; } }; }); @@ -233,10 +275,11 @@ 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(); - 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/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); @@ -150,9 +152,7 @@ export function createColumns( const { type, colorClass } = getProps(); return { render: () => - ` - ${type || '-'} - ` + `${type || '-'}` }; }); return renderSnippet(typeSnippet, { type: invoiceType, colorClass }); @@ -161,6 +161,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 +211,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 +242,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..870a2c38 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,28 @@ selection[id.toString()] = true; }); return selection; + }, + get sorting() { + return sorting; + } + }, + onStateChange: (updater: any) => { + if (onSortingChange) { + 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); + } } }, 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 +119,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/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 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 new file mode 100644 index 00000000..9cd4aa80 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -0,0 +1,1375 @@ + + +
+
+
+
+

Items de la Factura

+

+ Carga partidas, crea o aplica plantillas sin salir de esta vista. +

+
+
+ + + +
+
+ +
+ + + + 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 + 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 + {:else if showCrTrackingHeader} + Factura Impo + Línea + P/S + Clase + Descripcion Clase + Cant. Importada + U.M. + Preferencia + Contiene Subpartida + Partida Principal + {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} + Factura Impo + Línea + P/S + Clase + 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 + {:else} + P/S + 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 + toggleSort('warehouse')} + > +
+ Partida Principal + {#if sortField === 'warehouse'} + {#if sortOrder === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
+ {/if} + Acciones +
+
+ + {#if displayedItems.length === 0} + + + No hay items disponibles + + + {:else} + {#each displayedItems as item (item.id)} + handleRowClick(item)} + class="group/item-row cursor-pointer transition-colors hover:bg-muted/50 {focusedLine?.id === + item.id + ? 'bg-muted ring-1 ring-primary/20 ring-inset' + : ''}" + > + {#if operationType === 1} + {item.line_number} + {item.fa_data?.search_invoice || '-'} + {item.fa_data?.search_line || '-'} + {item.is_subitem ? 'S' : 'P'} + {item.quantity?.quantity || '0'} + {item.class_code || '-'} + {item.part_number_display || '-'} + + {item.description?.description_spanish || '-'} + + {item.fa_data?.contains_subitems ? 'Sí' : 'No'} + {item.warehouse || '-'} + {:else if showCrTrackingHeader} + {item.line_number} + {item.fa_data?.search_invoice || '-'} + {item.fa_data?.search_line || '-'} + {item.is_subitem ? 'S' : 'P'} + {item.class_code || '-'} + + {item.description?.description_spanish || '-'} + + {item.quantity?.quantity || '0'} + {item.unit_of_measure_code || '-'} + {item.reference_number || '-'} + {item.fa_data?.contains_subitems ? 'Sí' : 'No'} + {item.warehouse || '-'} + {:else if invoiceType === 'REP' || invoiceType === 'REPAR'} + {item.line_number} + {item.fa_data?.search_invoice || '-'} + {item.fa_data?.search_line || '-'} + {item.is_subitem ? 'S' : 'P'} + {item.class_code || '-'} + {item.part_number_display || '-'} + + {item.description?.description_spanish || '-'} + + {item.quantity?.quantity || '0'} + {item.unit_of_measure_code || '-'} + {item.reference_number || '-'} + {item.fa_data?.contains_subitems ? 'Sí' : 'No'} + {item.warehouse || '-'} + {:else} + {item.line_number} + {item.is_subitem ? 'S' : 'P'} + {item.class_code || '-'} + {item.class_description || '-'} + {item.quantity?.quantity || '0'} + {item.unit_of_measure_code || '-'} + {item.reference_number || '-'} + {item.fa_data?.contains_subitems ? 'Sí' : 'No'} + {item.warehouse || '-'} + {/if} + +
+ + +
+
+
+ {/each} + {#if isLoadingMore} + + + Cargando más items... + + + {/if} + {/if} +
+
+
+ + {#if flattenedLines.length > 0} +
+ Mostrando {displayedItems.length} de {flattenedLines.length} líneas +
+ {/if} +
+ +
+ {#if operationType === 1} + +
+

+ Descripción en español: +

+
+ {#if focusedLine} +

+ {focusedLine.description?.description_spanish || 'Sin descripción disponible.'} +

+ {:else} +

+ Selecciona una fila para ver la descripción. +

+ {/if} +
+
+ {/if} + + +
+
+

Cantidades:

+
+
+
+ Partidas: {items.length || 0} +
+
+ Bultos: 0 +
+
+
+ Importada:{imported || 0}
+ Peso neto: {net_weight || 0}
+ Peso bruto: {gross_weight || 0}
+
+ +

+ Valores de importacion: +

+ Dolares:0 USD
+ Pesos: 0 MXN
+ De Captura: 0 USD + +

+ spacer +

+ + Aduana:0 USD
+ Aduana: 0 MXN
+
+
+
+ + + + + + Confirmar Eliminación + + ¿Está seguro que desea eliminar este item? Esta acción no se puede deshacer. + + + + + + + + + + + { + if (!open) { + showUsePresetDialog = false; + selectedPreset = null; + } + }} +> + + +
+
+ Usar plantilla + + Selecciona una plantilla predefinida para cargar sus partidas. + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ {#if isLoadingPresets} +
+ + Cargando... +
+ {:else if filteredPresets.length === 0} +
+ +

No se encontraron plantillas

+
+ {:else} + {#each filteredPresets as preset} + + {/each} + {/if} +
+
+ + +
+ {#if !selectedPreset} +
+
+ +
+

Selecciona una plantilla para ver sus detalles

+
+ {:else} +
+ +
+
+

+ {selectedPreset.name} +

+

+ {selectedPreset.description || 'Sin descripción disponible.'} +

+
+
+
+ Creada +
+
+ {selectedPreset.created_at + ? new Date(selectedPreset.created_at).toLocaleDateString(undefined, { + dateStyle: 'long' + }) + : '-'} +
+
+
+ + +
+ + + + # + Descripción del Item + Cant. + Costo (USD) + + + + {#if selectedPresetItems.length === 0} + + +
+ + Esta plantilla no contiene items. +
+
+
+ {:else} + {#each selectedPresetItems as item, i} + + + {i + 1} + + +
+ + {item?.description?.description_spanish || 'Sin descripción'} + + {#if item.reference_number} + + REF: {item.reference_number} + + {/if} +
+
+ + {item?.quantity?.quantity || 0} + + + ${(item?.financial?.unit_cost_usd || 0).toLocaleString(undefined, { + minimumFractionDigits: 2 + })} + +
+ {/each} + {/if} +
+
+
+
+ {/if} +
+
+ + +
+ + +
+
+
+ + { + if (!open) { + createPresetName = ''; + createPresetDescription = ''; + } + }} +> + + + Crear plantilla + Guarda los elementos actuales como una plantilla reutilizable para inyectar en otras + partidas. + + +
+
+
+ + +
+
+ + +
+ + +
+
+ {: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) { 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)} />