diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index 75049871..5cfb7601 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -240,4 +240,15 @@ class ClassWithFADataResponse(BaseModel): eccn_code: Optional[str] = None class_enabled: Optional[bool] = None + model_config = ConfigDict(from_attributes=True) + + +class ClassWithFADataPaginatedResponse(BaseModel): + """Lista paginada de clases con datos FA (fixed-asset-classes)""" + + items: list[ClassWithFADataResponse] + total: int + page: int + page_size: int + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/classes/routes.py b/backend/api/v1/modules/a76/classes/routes.py index 871b0f64..0bce94a2 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, Optional +from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session @@ -10,7 +10,14 @@ from core.database import get_core_db from core.security import get_current_user from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource -from .dto import ClassCreateDTO, ClassCreateDTOFA, ClassResponseDTO, ClassResponseDTOFA, ClassUpdateDTO, ClassWithFADataResponse +from .dto import ( + ClassCreateDTO, + ClassCreateDTOFA, + ClassResponseDTO, + ClassResponseDTOFA, + ClassUpdateDTO, + ClassWithFADataPaginatedResponse, +) from .service import ClassService from api.v1.modules.a76.layouts_csv.classes.routes import router as imports_router @@ -24,17 +31,21 @@ router.include_router(imports_router, prefix="/imports", tags=["a76 / classes / # This ensures they have priority over the generic /{id} route @router.get( "/with-fa-data", - response_model=List[ClassWithFADataResponse], + response_model=ClassWithFADataPaginatedResponse, summary="Get Classes with FA Data", - description="Get all classes with their FA data in a single query (eliminates N+1 problem)", + description="Clases con datos FA en una sola consulta (JOIN); respuesta paginada", tags=["a76 / classes"], ) 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"), + page_size: int = Query(50, 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)"), + class_code: Optional[str] = Query(None, description="Filter by class code (contains)"), + description: Optional[str] = Query(None, description="Filter by ES/EN description (contains)"), + material_key: Optional[str] = Query(None, description="Filter by material key (contains)"), + fraction: Optional[str] = Query(None, description="Filter by tariff fraction (contains)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -45,6 +56,18 @@ async def get_classes_with_fa_data( tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"]) skip = (page - 1) * page_size + + filters: Optional[Dict[str, Any]] = None + if any([class_code, description, material_key, fraction]): + filters = {} + if class_code: + filters["class_code"] = class_code + if description: + filters["description"] = description + if material_key: + filters["material_key"] = material_key + if fraction: + filters["fraction"] = fraction classes_with_fa, total = ClassService.get_all_with_fa_data( db=db, @@ -52,11 +75,17 @@ async def get_classes_with_fa_data( company_id=company_id, skip=skip, limit=page_size, + filters=filters, sort_by=sort_by, sort_order=sort_order, ) - return classes_with_fa + return ClassWithFADataPaginatedResponse( + items=classes_with_fa, + total=total, + page=page, + page_size=page_size, + ) @router.post( "/fa", diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index 6f0eb86b..d7ff36d9 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -52,6 +52,14 @@ export interface A76ClassListResponse { size?: number; } +/** Respuesta de GET /v1/a76/classes/with-fa-data */ +export interface A76ClassWithFADataPageResponse { + items: A76Class[]; + total: number; + page: number; + page_size: number; +} + export interface A76ClassListParams { company_id: number; page?: number; @@ -110,7 +118,7 @@ export const classesApi = { }, /** - * Get all classes with FA data in a single query (optimized, eliminates N+1) + * Clases con datos FA (JOIN); respuesta paginada desde el servidor. */ getWithFAData: (params: { company_id: number; @@ -118,15 +126,23 @@ export const classesApi = { page_size?: number; sort_by?: string; sort_order?: 'asc' | 'desc'; - }): Promise> => { + class_code?: string; + description?: string; + material_key?: string; + fraction?: string; + }): Promise> => { const query = new URLSearchParams({ company_id: params.company_id.toString(), page: (params.page || 1).toString(), - page_size: (params.page_size || 1000).toString() + page_size: (params.page_size || 50).toString() }); - + if (params.sort_by) query.append('sort_by', params.sort_by); if (params.sort_order) query.append('sort_order', params.sort_order); + if (params.class_code) query.append('class_code', params.class_code); + if (params.description) query.append('description', params.description); + if (params.material_key) query.append('material_key', params.material_key); + if (params.fraction) query.append('fraction', params.fraction); return api.get(`/v1/a76/classes/with-fa-data?${query.toString()}`); } }; \ No newline at end of file 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 4feec016..9462269f 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -9,7 +9,6 @@ import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes'; 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'; import { currentUser, userHasPermission } from '$lib/auth'; @@ -35,7 +34,7 @@ fda_key?: string | null; } - // Estado de la lista de clases + // Estado de la lista de clases (servidor: filtros + paginación) let classes = $state([]); let selectedClassIds = $state([]); let selectedClass = $state(null); @@ -44,6 +43,17 @@ let searchDescription = $state(''); let searchType = $state(''); let searchFraction = $state(''); + const PAGE_SIZE = 50; + let currentPage = $state(1); + let totalItems = $state(0); + let debouncedFilters = $state({ + class_code: '', + description: '', + material_key: '', + fraction: '' + }); + let lastCompanyId = $state(undefined); + let loadSeq = 0; let showInsertDialog = $state(false); let showDeleteDialog = $state(false); let validationError = $state(''); @@ -75,32 +85,42 @@ bom: '' }); - // Clases filtradas según búsqueda (mantenemos filtrado local para compatibilidad inmediata) - const filteredClasses = $derived( - classes.filter((c) => { - 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); - 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; - }) - ); + const totalPages = $derived(Math.max(1, Math.ceil(totalItems / PAGE_SIZE))); + const rangeStart = $derived(totalItems === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1); + const rangeEnd = $derived(Math.min(currentPage * PAGE_SIZE, totalItems)); - // 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; - if (companyId) { - loadClasses(); + if (companyId === undefined) return; + if (lastCompanyId !== undefined && lastCompanyId !== companyId) { + currentPage = 1; } + lastCompanyId = companyId; + }); + + $effect(() => { + const s = searchTerm; + const d = searchDescription; + const t = searchType; + const f = searchFraction; + const handle = setTimeout(() => { + const next = { + class_code: s, + description: d, + material_key: t, + fraction: f + }; + const same = + debouncedFilters.class_code === next.class_code && + debouncedFilters.description === next.description && + debouncedFilters.material_key === next.material_key && + debouncedFilters.fraction === next.fraction; + if (!same) { + debouncedFilters = next; + currentPage = 1; + } + }, 400); + return () => clearTimeout(handle); }); async function loadClasses() { @@ -110,20 +130,32 @@ return; } + const seq = ++loadSeq; isLoading = true; try { - // Use the new consolidated endpoint that fetches classes + FA data in ONE query const response = await classesApi.getWithFAData({ company_id: companyId, - page: 1, - page_size: 1000, - sort_by: sorting.length > 0 ? sorting[0].id : undefined, - sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined + page: currentPage, + page_size: PAGE_SIZE, + sort_by: sorting.length > 0 ? String(sorting[0].id) : undefined, + sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined, + ...(debouncedFilters.class_code.trim() && { + class_code: debouncedFilters.class_code.trim() + }), + ...(debouncedFilters.description.trim() && { + description: debouncedFilters.description.trim() + }), + ...(debouncedFilters.material_key.trim() && { + material_key: debouncedFilters.material_key.trim() + }), + ...(debouncedFilters.fraction.trim() && { fraction: debouncedFilters.fraction.trim() }) }); + if (seq !== loadSeq) return; + if (response.data) { - // Data already comes with FA fields embedded - classes = response.data as FixedAssetClassExtended[]; + classes = response.data.items as FixedAssetClassExtended[]; + totalItems = response.data.total; handleSelectedIdsChange( selectedClassIds.filter((id) => classes.some((item) => item.id === id)) ); @@ -138,10 +170,22 @@ } toast.error('Error al cargar las clases de activo fijo'); } finally { - isLoading = false; + if (seq === loadSeq) { + isLoading = false; + } } } + $effect(() => { + if (!canView) return; + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + void currentPage; + void debouncedFilters; + void sorting; + loadClasses(); + }); + function selectClass(cls: A76Class) { if (!canView) return; selectedClass = cls as FixedAssetClassExtended; @@ -380,7 +424,11 @@

Listado de Clases

- Mostrando de {filteredClasses.length} registros + {#if totalItems === 0} + Sin resultados + {:else} + Mostrando {rangeStart}–{rangeEnd} de {totalItems} (pág. {currentPage}/{totalPages}) + {/if}
-
- (sorting = newSorting)} - /> +
+
+ { + sorting = newSorting; + currentPage = 1; + }} + /> +
+
+ + {PAGE_SIZE} por página + +
+ + +
+
diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 91fc58f3..b0f5aa83 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -1265,7 +1265,7 @@ const response = await api.get( `/v1/a76/classes/with-fa-data?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` ); - assetClasses = response.data || []; + assetClasses = response.data?.items ?? []; dialogOpen = true; } catch (error: any) { console.error('Error cargando clases:', error); @@ -1339,7 +1339,7 @@ const response = await api.get( `/v1/a76/classes/with-fa-data?company_id=${companyId}&page_size=1000${config.shelter ? '&all_companies=true' : ''}` ); - assetClasses = response.data || []; + assetClasses = response.data?.items ?? []; dialogOpen = true; } catch (error: any) { console.error('Error cargando clases:', error); diff --git a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte index 610f89c9..282ee2af 100644 --- a/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte +++ b/frontend/src/routes/dashboard/reports/partes-descargadas/+page.svelte @@ -211,7 +211,7 @@ materialTypes = materialTypesResponse.data?.items || []; customsSections = customsSectionsResponse.data?.items || []; partsCatalog = partsResponse.data?.items || []; - classesCatalog = classesResponse.data || []; + classesCatalog = classesResponse.data?.items ?? []; } catch (error) { console.error('Error cargando catálogos para Partes descargadas:', error); toast.error('No se pudieron cargar algunos catálogos');