feature/clases-paginacion

This commit is contained in:
2026-04-29 09:51:50 -06:00
parent d002c26d46
commit d78163da12
6 changed files with 197 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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