From 95940ed91e8e0ec86d0b4ec32bba08c77d6eb654 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 17 Feb 2026 12:51:47 -0600 Subject: [PATCH] feat: Add dialog for creating and editing historical tariff fractions and implement deterministic sorting for fraction listings. --- .../canadian_tariff_fractions/service.py | 2 + .../historical_tariff_fractions/service.py | 2 + .../fractions/tariff_fractions/service.py | 2 + .../public/reference_data/sectors/routes.py | 2 + .../v1/modules/sitar/common/base_service.py | 2 + .../historical-tariff-fractions.ts | 68 ++++- .../sectors/SectorsList.svelte | 87 +++--- .../fractions/CanadianFractionList.svelte | 113 +++++--- .../fractions/HistoricalFractionDialog.svelte | 262 ++++++++++++++++++ .../fractions/HistoricalFractionList.svelte | 222 +++++++++++---- .../goods/fractions/TariffFractionList.svelte | 120 ++++---- 11 files changed, 694 insertions(+), 188 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionDialog.svelte diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py index 82b5a648..8fec1468 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/service.py @@ -36,6 +36,8 @@ class CanadianTariffFractionService: ) total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one() + # Add deterministic sort order + query = query.order_by(CanadianTariffFraction.fraction) items = self.db.scalars(query.offset(skip).limit(limit)).all() return items, total diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py index f585a015..01579a4f 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py @@ -33,6 +33,8 @@ class HistoricalTariffFractionService: query = query.where(HistoricalTariffFraction.historical_fraction.ilike(f"%{historical_fraction}%")) total = self.db.execute(select(func.count()).select_from(query.subquery())).scalar_one() + # Add deterministic sort order + query = query.order_by(HistoricalTariffFraction.historical_fraction) items = self.db.scalars(query.offset(skip).limit(limit)).all() return items, total diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index 24669f33..7a00b364 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -291,6 +291,8 @@ class TariffFractionService: query = query.filter(TariffFraction.umt.ilike(f"%{filters['umt']}%")) total = query.count() + # Add deterministic sort order + query = query.order_by(TariffFraction.fraction) items = query.offset(skip).limit(limit).all() return items, total diff --git a/backend/api/v1/modules/public/reference_data/sectors/routes.py b/backend/api/v1/modules/public/reference_data/sectors/routes.py index 46d44779..da38ac7d 100644 --- a/backend/api/v1/modules/public/reference_data/sectors/routes.py +++ b/backend/api/v1/modules/public/reference_data/sectors/routes.py @@ -32,6 +32,8 @@ def list_sectors( query = query.filter(search_filter) total = query.count() + # Add deterministic sort order + query = query.order_by(Sector.key) items = query.offset(skip).limit(page_size).all() return { diff --git a/backend/api/v1/modules/sitar/common/base_service.py b/backend/api/v1/modules/sitar/common/base_service.py index 0795a539..b4dbe284 100644 --- a/backend/api/v1/modules/sitar/common/base_service.py +++ b/backend/api/v1/modules/sitar/common/base_service.py @@ -86,6 +86,8 @@ class SitarAPIBaseService: httpx.HTTPError: If request fails """ token = await self._get_token() + # Ensure no double slash between fractures and endpoint + endpoint = endpoint.lstrip("/") url = f"{self.base_url}/fractions/{endpoint}" headers = { "Authorization": f"Bearer {token}", diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts index 79b2ae75..99d9689a 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions.ts @@ -19,6 +19,40 @@ export interface HistoricalFraction { end_date: string | null; } +export interface HistoricalFractionCreate { + historical_fraction: string; + unit_of_measure_code?: string | null; + country?: string | null; + fraction_type?: string | null; + sector?: string | null; + import_tax_rate?: number | null; + export_tax_rate?: number | null; + publication_date?: string | null; + is_immex?: boolean | null; + normal_temporality?: boolean | null; + services_temporality?: boolean | null; + certified_temporality?: boolean | null; + by_log?: boolean | null; + end_date?: string | null; +} + +export interface HistoricalFractionUpdate { + historical_fraction?: string; + unit_of_measure_code?: string | null; + country?: string | null; + fraction_type?: string | null; + sector?: string | null; + import_tax_rate?: number | null; + export_tax_rate?: number | null; + publication_date?: string | null; + is_immex?: boolean | null; + normal_temporality?: boolean | null; + services_temporality?: boolean | null; + certified_temporality?: boolean | null; + by_log?: boolean | null; + end_date?: string | null; +} + export interface HistoricalFractionList { items: HistoricalFraction[]; total: number; @@ -41,15 +75,29 @@ export async function getHistoricalFractions( if (historicalFraction) params.append('historical_fraction', historicalFraction); - const response = await api.get<{ message?: string, items?: HistoricalFraction[], total?: number }>(`/v1/a76/fractions/historical-tariff-fractions/?${params.toString()}`); - - // Handle potential wrapper response - if (response.data && 'items' in response.data) { - return response.data as unknown as HistoricalFractionList; - } - + const response = await api.get(`/v1/a76/fractions/historical-tariff-fractions/?${params.toString()}`); if (!response.data) throw new Error('Error fetching historical fractions'); - - // Fallback - return response.data as unknown as HistoricalFractionList; + return response.data; +} + +export async function createHistoricalFraction( + companyId: number, + data: HistoricalFractionCreate +): Promise> { + return await api.post(`/v1/a76/fractions/historical-tariff-fractions/?company_id=${companyId}`, data); +} + +export async function updateHistoricalFraction( + companyId: number, + id: number, + data: HistoricalFractionUpdate +): Promise> { + return await api.put(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`, data); +} + +export async function deleteHistoricalFraction( + companyId: number, + id: number +): Promise> { + return await api.delete(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`); } diff --git a/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte index 6f60f61a..78137b1f 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/sectors/SectorsList.svelte @@ -3,22 +3,24 @@ import { Button } from '$lib/components/ui/button'; import * as Table from '$lib/components/ui/table'; import * as Card from '$lib/components/ui/card'; - import { onMount } from 'svelte'; + import { onMount, untrack } from 'svelte'; import { toast } from 'svelte-sonner'; import { getSectors, type Sector } from '$lib/api/dashboard/general_catalogs/sectors'; import { Loader2, Search } from 'lucide-svelte'; import Badge from '$lib/components/ui/badge/badge.svelte'; - export let title = 'Sectores'; + let { title = 'Sectores' }: { title?: string } = $props(); - let sectors: Sector[] = []; - let loading = false; - let searchTerm = ''; - let page = 1; + let sectors = $state([]); + let loading = $state(false); + let searchTerm = $state(''); + let page = $state(1); let pageSize = 50; - let hasMore = true; - let total = 0; + let hasMore = $state(true); + let total = $state(0); let searchTimeout: ReturnType; + let observer: IntersectionObserver; + let sentinel: HTMLDivElement; async function loadSectors(reset = false) { if (loading || (!hasMore && !reset)) return; @@ -28,22 +30,27 @@ page = 1; sectors = []; hasMore = true; + } else { + page++; } try { const response = await getSectors(page, pageSize, searchTerm || undefined); - if (response.items.length === 0) { - hasMore = false; + const newItems = response.items || []; + if (reset) { + sectors = newItems; } else { - sectors = reset ? response.items : [...sectors, ...response.items]; - total = response.total; - hasMore = sectors.length < total; - if (sectors.length >= total) hasMore = false; + sectors = [...sectors, ...newItems]; } + + total = response.total; + // Safer end-of-data detection + hasMore = newItems.length === pageSize && sectors.length < total; } catch (error) { console.error('Error loading sectors:', error); toast.error('Error al cargar sectores'); + hasMore = false; } finally { loading = false; } @@ -67,15 +74,32 @@ } } - function handleLoadMore() { - if (!loading && hasMore) { - page++; - loadSectors(); - } + function setupObserver() { + if (observer) observer.disconnect(); + + observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading && sectors.length > 0) { + loadSectors(false); + } + }, + { rootMargin: '100px' } + ); + + if (sentinel) observer.observe(sentinel); } - onMount(() => { - loadSectors(true); + // Initial load + $effect(() => { + untrack(() => loadSectors(true)); + }); + + // Setup observer only when sentinel is available + $effect(() => { + if (sentinel) { + setupObserver(); + return () => observer?.disconnect(); + } }); @@ -139,6 +163,15 @@ {/each} {/if} + {#if loading && page > 1} + + +
+ +
+
+
+ {/if} @@ -147,16 +180,8 @@
Mostrando {sectors.length} de {total} registros
- {#if hasMore} - - {/if} + +
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte index fbd9da4d..7513162c 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/CanadianFractionList.svelte @@ -1,5 +1,5 @@ @@ -150,15 +200,7 @@ - {#if loading} - - -
- -
-
-
- {:else if fractions.length === 0} + {#if fractions.length === 0 && !loading} No se encontraron resultados {/each} {/if} + {#if loading} + + +
+ +
+
+
+ {/if}
- -
- -
- Página {page} de {totalPages || 1} -
- -
+ +
+ import * as Dialog from '$lib/components/ui/dialog'; + import { Input } from '$lib/components/ui/input'; + import { Label } from '$lib/components/ui/label'; + import { Button } from '$lib/components/ui/button'; + import { Switch } from '$lib/components/ui/switch'; + import { + createHistoricalFraction, + updateHistoricalFraction, + type HistoricalFraction, + type HistoricalFractionCreate, + type HistoricalFractionUpdate + } from '$lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions'; + import { companyStore } from '$lib/stores/company.svelte'; + import { toast } from 'svelte-sonner'; + import { Loader2 } from 'lucide-svelte'; + + let { + open = $bindable(false), + fraction = null, // If null, create mode. If set, edit mode. + onSuccess + }: { + open: boolean; + fraction?: HistoricalFraction | null; + onSuccess: () => void; + } = $props(); + + let isLoading = $state(false); + + // Form fields + let historicalFractionCode = $state(''); + let unitOfMeasureCode = $state(''); + let country = $state(''); + let fractionType = $state(''); + let sector = $state(''); + let importTaxRate = $state(''); + let exportTaxRate = $state(''); + let publicationDate = $state(''); + let endDate = $state(''); + let isImmex = $state(false); + let normalTemporality = $state(false); + let servicesTemporality = $state(false); + let certifiedTemporality = $state(false); + let byLog = $state(false); + + // Load data on open/fraction change + $effect(() => { + if (open) { + if (fraction) { + // Edit mode + historicalFractionCode = fraction.historical_fraction || ''; + unitOfMeasureCode = fraction.unit_of_measure_code || ''; + country = fraction.country || ''; + fractionType = fraction.fraction_type || ''; + sector = fraction.sector || ''; + importTaxRate = fraction.import_tax_rate?.toString() || ''; + exportTaxRate = fraction.export_tax_rate?.toString() || ''; + publicationDate = fraction.publication_date ? fraction.publication_date.split('T')[0] : ''; + endDate = fraction.end_date ? fraction.end_date.split('T')[0] : ''; + isImmex = fraction.is_immex || false; + normalTemporality = fraction.normal_temporality || false; + servicesTemporality = fraction.services_temporality || false; + certifiedTemporality = fraction.certified_temporality || false; + byLog = fraction.by_log || false; + } else { + // Create mode - reset + historicalFractionCode = ''; + unitOfMeasureCode = ''; + country = ''; + fractionType = ''; + sector = ''; + importTaxRate = ''; + exportTaxRate = ''; + publicationDate = ''; + endDate = ''; + isImmex = false; + normalTemporality = false; + servicesTemporality = false; + certifiedTemporality = false; + byLog = false; + } + } + }); + + async function handleSubmit() { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + + // Validation + if (!historicalFractionCode) { + toast.error('La fracción es requerida'); + return; + } + + isLoading = true; + try { + const importRateNum = importTaxRate ? parseFloat(importTaxRate) : undefined; + const exportRateNum = exportTaxRate ? parseFloat(exportTaxRate) : undefined; + + const baseData = { + historical_fraction: historicalFractionCode, + unit_of_measure_code: unitOfMeasureCode || null, + country: country || null, + fraction_type: fractionType || null, + sector: sector || null, + import_tax_rate: importRateNum, + export_tax_rate: exportRateNum, + publication_date: publicationDate || null, + end_date: endDate || null, + is_immex: isImmex, + normal_temporality: normalTemporality, + services_temporality: servicesTemporality, + certified_temporality: certifiedTemporality, + by_log: byLog + }; + + if (fraction) { + // Update + const updateData: HistoricalFractionUpdate = baseData; + await updateHistoricalFraction(companyId, fraction.id, updateData); + toast.success('Fracción actualizada correctamente'); + } else { + // Create + const createData: HistoricalFractionCreate = { + ...baseData, + historical_fraction: historicalFractionCode // Required in create + }; + await createHistoricalFraction(companyId, createData); + toast.success('Fracción creada correctamente'); + } + onSuccess(); + open = false; + } catch (error) { + console.error('Error saving historical fraction:', error); + toast.error('Error al guardar la fracción'); + } finally { + isLoading = false; + } + } + + + + + + {fraction ? 'Editar' : 'Crear'} Fracción Histórica + + {fraction + ? 'Modifica los detalles de la fracción seleccionada.' + : 'Ingresa los datos para la nueva fracción.'} + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + {byLog ? 'Sí' : 'No'} +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte index b5998419..f16442a6 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/HistoricalFractionList.svelte @@ -1,14 +1,16 @@
-
-
- -
- - +
+
+
+ +
+ + +
+
@@ -109,20 +202,13 @@ Fecha Fin IGI IGE + Acciones - {#if loading} + {#if fractions.length === 0 && !loading} - -
- -
-
-
- {:else if fractions.length === 0} - - No se encontraron resultados @@ -145,33 +231,53 @@ > {fraction.import_tax_rate ?? '-'} {fraction.export_tax_rate ?? '-'} + +
+ + +
+
{/each} {/if} + {#if loading} + + +
+ +
+
+
+ {/if}
- -
- -
- Página {page} de {totalPages || 1} -
- -
+ +
+ +
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte index 997e2776..15e75d87 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte @@ -17,7 +17,7 @@ type TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions'; import { companyStore } from '$lib/stores/company.svelte'; - import { onMount } from 'svelte'; + import { onMount, untrack } from 'svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import TariffFractionFormDialog from './TariffFractionFormDialog.svelte'; import { toast } from 'svelte-sonner'; @@ -41,6 +41,11 @@ let isLoading = $state(false); let search = $state(''); let searchTimeout: ReturnType; + let observer: IntersectionObserver; + let sentinel: HTMLDivElement; + + // Infinite scroll state + let hasMore = $state(true); let isFormDialogOpen = $state(false); let selectedFraction = $state(null); @@ -50,11 +55,19 @@ let showDeleteConfirm = $state(false); let fractionToDelete = $state(null); - async function loadFractions() { + async function loadFractions(reset = false) { const companyId = companyStore.activeCompany?.id; if (!companyId) return; + if (isLoading) return; isLoading = true; + + if (reset) { + currentPage = 1; + fractions = []; + hasMore = true; + } + try { const filters: Record = {}; if (search) filters.search = search; @@ -64,12 +77,24 @@ const response = await getTariffFractions(currentPage, pageSize, companyId, filters); if (response.data) { - fractions = response.data.items; + const newItems = response.data.items || []; + if (reset) { + fractions = newItems; + } else { + fractions = [...fractions, ...newItems]; + } totalFractions = response.data.total; + + // Safer end-of-data detection + hasMore = newItems.length === pageSize && fractions.length < totalFractions; + } else { + if (reset) fractions = []; + hasMore = false; } } catch (error) { console.error('Error loading fractions:', error); toast.error('Error al cargar las fracciones'); + hasMore = false; } finally { isLoading = false; } @@ -78,8 +103,7 @@ function handleSearchInput() { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - currentPage = 1; - loadFractions(); + loadFractions(true); }, 500); } @@ -88,6 +112,21 @@ loadFractions(); } + function setupObserver() { + if (observer) observer.disconnect(); + + observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading && fractions.length > 0) { + handlePageChange(currentPage + 1); + } + }, + { rootMargin: '100px' } + ); + + if (sentinel) observer.observe(sentinel); + } + function openCreateDialog() { selectedFraction = null; isManageMode = false; // Create mode @@ -113,7 +152,7 @@ // or just local overrides. Assuming Service handles logic. await deleteTariffFraction(fractionToDelete.id, companyStore.activeCompany.id, catalog); toast.success('Fracción eliminada correctamente'); - loadFractions(); + loadFractions(true); } catch (error) { console.error('Error deleting fraction:', error); toast.error('Error al eliminar la fracción. Puede que esté en uso.'); @@ -122,17 +161,21 @@ fractionToDelete = null; } } - - onMount(() => { - if (companyStore.activeCompany) { - loadFractions(); - } - }); + // Removed onMount as we use $effect for company changes which covers initial load // Reload when company changes $effect(() => { - if (companyStore.activeCompany?.id) { - loadFractions(); + const companyId = companyStore.activeCompany?.id; + if (companyId) { + untrack(() => loadFractions(true)); + } + }); + + // Setup observer only when sentinel is available + $effect(() => { + if (sentinel) { + setupObserver(); + return () => observer?.disconnect(); } }); @@ -176,18 +219,7 @@ - {#if isLoading} - - -
- -
-
-
- {:else if fractions.length === 0} + {#if fractions.length === 0 && !isLoading} {/each} {/if} + {#if isLoading} + + +
+ +
+
+
+ {/if}
- -
- -
- Página {currentPage} de {Math.ceil(totalFractions / pageSize) || 1} -
- -
+ +