diff --git a/backend/api/v1/modules/public/reference_data/conftest.py b/backend/api/v1/modules/public/reference_data/conftest.py index 2b5b3377..58e30506 100644 --- a/backend/api/v1/modules/public/reference_data/conftest.py +++ b/backend/api/v1/modules/public/reference_data/conftest.py @@ -12,6 +12,9 @@ def access_token(): @pytest.fixture(scope="session") def client(): + from api.v1.modules.public.reference_data.countries.routes import router as countries_router + from api.v1.modules.public.reference_data.transport_types.routes import router as transport_types_router app = FastAPI() - app.include_router(router) + app.include_router(countries_router) + app.include_router(transport_types_router) return TestClient(app) diff --git a/backend/api/v1/modules/public/reference_data/countries/routes.py b/backend/api/v1/modules/public/reference_data/countries/routes.py index 4bd30d05..45d76eea 100644 --- a/backend/api/v1/modules/public/reference_data/countries/routes.py +++ b/backend/api/v1/modules/public/reference_data/countries/routes.py @@ -15,13 +15,26 @@ router = APIRouter(prefix="/countries") async def list_countries( page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"), + search: str = Query(None, description="Término de búsqueda"), db: Session = Depends(get_core_db), ): """Endpoint público para obtener lista de países - no requiere autenticación""" skip = (page - 1) * page_size query = db.query(Country) - items = query.offset(skip).limit(page_size).all() + + if search: + search_filter = f"%{search}%" + query = query.filter( + (Country.m3_key.ilike(search_filter)) | + (Country.mex_key.ilike(search_filter)) | + (Country.ame_key.ilike(search_filter)) | + (Country.description_es.ilike(search_filter)) | + (Country.description_en.ilike(search_filter)) + ) + total = query.count() + items = query.offset(skip).limit(page_size).all() + return { "items": [CountryDTO.model_validate(obj) for obj in items], "total": total, diff --git a/backend/api/v1/modules/public/reference_data/countries/test_countries.py b/backend/api/v1/modules/public/reference_data/countries/test_countries.py index ab4da9f8..603217ac 100644 --- a/backend/api/v1/modules/public/reference_data/countries/test_countries.py +++ b/backend/api/v1/modules/public/reference_data/countries/test_countries.py @@ -9,13 +9,17 @@ client = TestClient(app) @pytest.mark.usefixtures("client", "access_token") -def test_list_countries(client, access_token): +def test_list_countries_with_search(client, access_token): headers = {"Authorization": f"Bearer {access_token}"} - response = client.get("/countries/", headers=headers) + # Search for a known country from seed, e.g., "Mexico" or "MEX" + response = client.get("/countries/?search=Mexico", headers=headers) assert response.status_code == 200 - assert "items" in response.json() - assert "page" in response.json() - assert "page_size" in response.json() + data = response.json() + assert "items" in data + # Depending on seed data, there should be at least one item if "Mexico" exists + if data["items"]: + for item in data["items"]: + assert "mexico" in item["description_es"].lower() or "mexico" in item["description_en"].lower() @pytest.mark.usefixtures("client", "access_token") diff --git a/frontend/src/lib/api/dashboard/refrence_data/countries.ts b/frontend/src/lib/api/dashboard/refrence_data/countries.ts index f17eab64..09ed1927 100644 --- a/frontend/src/lib/api/dashboard/refrence_data/countries.ts +++ b/frontend/src/lib/api/dashboard/refrence_data/countries.ts @@ -5,34 +5,34 @@ import { api } from '$lib/api'; export interface Country { - m3_key: string; - mex_key: string; - ame_key: string; - description_es: string; - description_en: string; + m3_key: string; + mex_key: string; + ame_key: string; + description_es: string; + description_en: string; } export interface CountryListResponse { - items: Country[]; - total: number; - page: number; - page_size: number; + items: Country[]; + total: number; + page: number; + page_size: number; } export interface CreateCountryData { - m3_key: string; - mex_key: string; - ame_key: string; - description_es: string; - description_en: string; + m3_key: string; + mex_key: string; + ame_key: string; + description_es: string; + description_en: string; } export interface UpdateCountryData { - m3_key?: string; - mex_key?: string; - ame_key?: string; - description_es?: string; - description_en?: string; + m3_key?: string; + mex_key?: string; + ame_key?: string; + description_es?: string; + description_en?: string; } /** @@ -43,18 +43,21 @@ export const countriesApi = { * Lista todos los países con paginación * @param page - Número de página (por defecto 1) * @param pageSize - Tamaño de página (por defecto 50) + * @param search - Término de búsqueda (opcional) */ - list: (page = 1, pageSize = 50) => - api.get( - // CORREGIDO: Añadido '/' antes del '?' - `/v1/public/refrence_data/countries/?page=${page}&page_size=${pageSize}` - ), + list: (page = 1, pageSize = 50, search?: string) => { + let url = `/v1/public/refrence_data/countries/?page=${page}&page_size=${pageSize}`; + if (search) { + url += `&search=${encodeURIComponent(search)}`; + } + return api.get(url); + }, /** * Obtiene un país por su clave M3 * @param m3_key - Clave M3 del país */ - get: (m3_key: string) => + get: (m3_key: string) => // CORREGIDO: Añadido '/' al final api.get(`/v1/public/refrence_data/countries/${m3_key}/`), @@ -79,7 +82,7 @@ export const countriesApi = { * Elimina un país * @param m3_key - Clave M3 del país a eliminar */ - delete: (m3_key: string) => + delete: (m3_key: string) => // CORREGIDO: Añadido '/' después de la clave api.delete(`/v1/public/refrence_data/countries/${m3_key}/`) }; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte index 27943e6b..e6d1f81d 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte @@ -19,80 +19,109 @@ // --- ESTADO --- let items = $state([]); let loading = $state(false); + let loadingMore = $state(false); let searchTerm = $state(""); - let loaded = $state(false); + let previousSearchTerm = ""; + let page = $state(1); + let pageSize = 50; + let hasMore = $state(true); + let totalItems = $state(0); + let observer: IntersectionObserver | null = null; + let bottomSentinel: HTMLElement | null = $state(null); + let searchTimeout: any; + let isInitialized = false; - // Filtro local - let filteredItems = $derived( - items.filter(i => - (i.m3_key || "").toLowerCase().includes(searchTerm.toLowerCase()) || - (i.mex_key || "").toLowerCase().includes(searchTerm.toLowerCase()) || - (i.description_es || "").toLowerCase().includes(searchTerm.toLowerCase()) || - (i.description_en || "").toLowerCase().includes(searchTerm.toLowerCase()) - ) - ); - - // Cargar datos al abrir + // Cargar datos iniciales al abrir $effect(() => { - console.log("CountrySelectorDialog: open changed", open); - if (open) { - loadCountries(); + if (open && !isInitialized) { + isInitialized = true; + previousSearchTerm = searchTerm; + resetAndLoad(); + } else if (!open) { + isInitialized = false; } }); - async function loadCountries() { - loading = true; - console.log("CountrySelectorDialog: loading countries..."); - try { - // FIX: Reducir tamaño de página para evitar timeouts y manejo de errores - const response = await countriesApi.list(1, 100); - console.log("Respuesta países FULL:", response); + // Manejar búsqueda con debouncing - solo cuando cambia el término + $effect(() => { + const term = searchTerm; + + // Solo resetear si el término cambió y ya estamos inicializados + if (isInitialized && term !== previousSearchTerm) { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + previousSearchTerm = term; + resetAndLoad(); + }, 500); + } + }); + // Configurar IntersectionObserver para infinite scroll + $effect(() => { + if (bottomSentinel && hasMore && !loading && !loadingMore && open) { + if (observer) observer.disconnect(); + + observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) { + loadMore(); + } + }, { threshold: 0.1 }); + + observer.observe(bottomSentinel); + } + + return () => { + if (observer) observer.disconnect(); + }; + }); + + async function resetAndLoad() { + page = 1; + items = []; + hasMore = true; + await loadCountries(true); + } + + async function loadMore() { + if (!hasMore || loading || loadingMore) return; + page += 1; + await loadCountries(false); + } + + async function loadCountries(isInitial: boolean) { + if (isInitial) { + loading = true; + } else { + loadingMore = true; + } + + try { + const response = await countriesApi.list(page, pageSize, searchTerm); + if (response.error) { - console.error("Error API:", response.error); - toast.error(`Error al cargar países: ${response.error}`); + toast.error(`Error: ${response.error}`); + hasMore = false; return; } + + const newItems = response.data?.items || []; + totalItems = response.data?.total || 0; - // Caso 1: Estructura esperada { data: { items: [...] } } - if (response.data?.items && Array.isArray(response.data.items)) { - items = response.data.items; - loaded = true; - } - // Caso 2: El backend devuelve el array directamente en data { data: [...] } - else if (Array.isArray(response.data)) { - items = response.data; - loaded = true; + if (isInitial) { + items = newItems; + } else { + items = [...items, ...newItems]; } - // Caso 3: La respuesta en sí es el array (poco probable con el wrapper actual pero posible si algo falla antes) - else if (Array.isArray(response)) { - items = response; - loaded = true; - } - // Caso 4: data es el objeto paginado pero sin la propiedad items correcta o vacía - else if (response.data && typeof response.data === 'object') { - // Intentar buscar alguna propiedad que sea array - const possibleArray = Object.values(response.data).find(val => Array.isArray(val)); - if (possibleArray) { - items = possibleArray as Country[]; - loaded = true; - } else { - console.warn("Estructura de datos no reconocida en countriesApi.list:", response.data); - toast.error("Formato de datos de países no reconocido"); - } - } - else { - console.warn("No se encontraron países o formato incorrecto:", response); - toast.error("No se encontraron países"); - } - - console.log(`Países cargados: ${items.length}`); + + hasMore = items.length < totalItems && newItems.length > 0; } catch (e: any) { - console.error("Error cargando países (excepción):", e); - toast.error(`Excepción al cargar países: ${e.message || e}`); + console.error("Error loading countries:", e); + toast.error("Error al conectar con el servidor"); + hasMore = false; } finally { loading = false; + loadingMore = false; } } @@ -103,11 +132,11 @@ - + Seleccionar País - Seleccione el país de origen del catálogo. + Seleccione el país de origen del catálogo. Escrolea para ver más. @@ -122,12 +151,12 @@
- {#if loading} + {#if loading && items.length === 0}

Cargando catálogo...

- {:else if filteredItems.length === 0} + {:else if items.length === 0}

No se encontraron países.

@@ -143,7 +172,7 @@ - {#each filteredItems as item} + {#each items as item} handleSelect(item)} @@ -172,12 +201,19 @@ {/each} + + +
+ {#if loadingMore} + + {/if} +
{/if}
- {filteredItems.length} registros encontrados + {items.length} de {totalItems} registros