Merge pull request 'Se corrigio el sistema de modal de paises' (#74) from fixed/catalogo_paises_partes into development

Reviewed-on: ADUANASOFT/anexo76#74
This commit is contained in:
2026-01-22 23:33:24 +00:00
5 changed files with 156 additions and 97 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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<CountryListResponse>(
// 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<CountryListResponse>(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<Country>(`/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}/`)
};

View File

@@ -19,80 +19,109 @@
// --- ESTADO ---
let items = $state<Country[]>([]);
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 @@
</script>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col">
<Dialog.Content class="sm:max-w-[900px] max-h-[90vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar País</Dialog.Title>
<Dialog.Description>
Seleccione el país de origen del catálogo.
Seleccione el país de origen del catálogo. Escrolea para ver más.
</Dialog.Description>
</Dialog.Header>
@@ -122,12 +151,12 @@
</div>
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading}
{#if loading && items.length === 0}
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredItems.length === 0}
{:else if items.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron países.</p>
</div>
@@ -143,7 +172,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredItems as item}
{#each items as item}
<Table.Row
class="cursor-pointer hover:bg-accent/50 transition-colors"
onclick={() => handleSelect(item)}
@@ -172,12 +201,19 @@
{/each}
</Table.Body>
</Table.Root>
<!-- Sentinel for Infinite Scroll -->
<div bind:this={bottomSentinel} class="h-10 flex items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
{filteredItems.length} registros encontrados
{items.length} de {totalItems} registros
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>