feature/clases-paginacion
This commit is contained in:
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ApiResponse<A76Class[]>> => {
|
||||
class_code?: string;
|
||||
description?: string;
|
||||
material_key?: string;
|
||||
fraction?: string;
|
||||
}): Promise<ApiResponse<A76ClassWithFADataPageResponse>> => {
|
||||
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()}`);
|
||||
}
|
||||
};
|
||||
@@ -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<FixedAssetClassExtended[]>([]);
|
||||
let selectedClassIds = $state<number[]>([]);
|
||||
let selectedClass = $state<FixedAssetClassExtended | null>(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<number | undefined>(undefined);
|
||||
let loadSeq = 0;
|
||||
let showInsertDialog = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let validationError = $state<string>('');
|
||||
@@ -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 @@
|
||||
<h2 class="text-sm font-semibold">Listado de Clases</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Mostrando de {filteredClasses.length} registros
|
||||
{#if totalItems === 0}
|
||||
Sin resultados
|
||||
{:else}
|
||||
Mostrando {rangeStart}–{rangeEnd} de {totalItems} (pág. {currentPage}/{totalPages})
|
||||
{/if}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" class="h-9" onclick={handleRefresh}>
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
@@ -390,17 +438,53 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabla de clases -->
|
||||
<div class="flex-1 overflow-hidden p-0">
|
||||
<DataTable
|
||||
data={filteredClasses}
|
||||
{columns}
|
||||
loading={isLoading}
|
||||
selectedIds={selectedClassIds}
|
||||
onSelectedIdsChange={handleSelectedIdsChange}
|
||||
onRowDoubleClick={handleRowDoubleClick}
|
||||
{sorting}
|
||||
onSortingChange={(newSorting) => (sorting = newSorting)}
|
||||
/>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<DataTable
|
||||
data={classes}
|
||||
{columns}
|
||||
loading={isLoading}
|
||||
selectedIds={selectedClassIds}
|
||||
onSelectedIdsChange={handleSelectedIdsChange}
|
||||
onRowDoubleClick={handleRowDoubleClick}
|
||||
{sorting}
|
||||
onSortingChange={(newSorting) => {
|
||||
sorting = newSorting;
|
||||
currentPage = 1;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-none flex-wrap items-center justify-between gap-2 border-t bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{PAGE_SIZE} por página
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
disabled={currentPage <= 1 || isLoading}
|
||||
onclick={() => {
|
||||
currentPage = Math.max(1, currentPage - 1);
|
||||
}}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
disabled={currentPage >= totalPages || isLoading || totalItems === 0}
|
||||
onclick={() => {
|
||||
currentPage = Math.min(totalPages, currentPage + 1);
|
||||
}}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user