feat: Add dialog for creating and editing historical tariff fractions and implement deterministic sorting for fraction listings.

This commit is contained in:
Galindo97
2026-02-17 12:51:47 -06:00
parent da43659eb3
commit 95940ed91e
11 changed files with 694 additions and 188 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HistoricalFractionList>(`/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<ApiResponse<HistoricalFraction>> {
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<ApiResponse<HistoricalFraction>> {
return await api.put(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`, data);
}
export async function deleteHistoricalFraction(
companyId: number,
id: number
): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/fractions/historical-tariff-fractions/${id}/?company_id=${companyId}`);
}

View File

@@ -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<Sector[]>([]);
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<typeof setTimeout>;
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();
}
});
</script>
@@ -139,6 +163,15 @@
</Table.Row>
{/each}
{/if}
{#if loading && page > 1}
<Table.Row>
<Table.Cell colspan={3} class="h-12 text-center">
<div class="flex justify-center">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
@@ -147,16 +180,8 @@
<div class="text-xs text-muted-foreground">
Mostrando {sectors.length} de {total} registros
</div>
{#if hasMore}
<Button variant="outline" onclick={handleLoadMore} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Cargando...
{:else}
Cargar más
{/if}
</Button>
{/if}
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import {
getCanadianFractions,
deleteCanadianFraction,
@@ -23,18 +23,31 @@
let pageSize = 50;
let searchTimeout: ReturnType<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
// Infinite scroll state
let hasMore = $state(true);
// Dialog state
let dialogOpen = $state(false);
let editingFraction = $state<CanadianFraction | null>(null);
let deletingFractionId = $state<number | null>(null);
async function loadFractions(targetPage = 1) {
async function loadFractions(reset = false) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (loading) return;
loading = true;
page = targetPage;
if (reset) {
page = 1;
fractions = [];
hasMore = true;
} else {
page++;
}
try {
const response = await getCanadianFractions(
@@ -43,25 +56,36 @@
pageSize,
searchQuery || undefined
);
fractions = response.items;
const newItems = response.items || [];
if (reset) {
fractions = newItems;
} else {
fractions = [...fractions, ...newItems];
}
totalItems = response.total;
totalPages = response.pages;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && fractions.length < totalItems;
} catch (error) {
console.error('Error loading Canadian fractions:', error);
toast.error('Error al cargar fracciones canadienses');
hasMore = false;
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(1);
loadFractions(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(1);
loadFractions(true);
}, 500);
}
@@ -92,7 +116,7 @@
deletingFractionId = fraction.id;
await deleteCanadianFraction(companyId, fraction.id);
toast.success('Fracción eliminada correctamente');
loadFractions(page);
loadFractions(true);
} catch (error) {
console.error('Error deleting Canadian fraction:', error);
toast.error('Error al eliminar la fracción');
@@ -102,12 +126,38 @@
}
function handleSuccess() {
loadFractions(page);
loadFractions(true);
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && fractions.length > 0) {
loadFractions(false);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
// Removed onMount as we use $effect for company changes which covers initial load
$effect(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
const companyId = companyStore.activeCompany?.id;
if (companyId) {
untrack(() => loadFractions(true));
}
});
// Setup observer only when sentinel is available
$effect(() => {
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</script>
@@ -150,15 +200,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{:else if fractions.length === 0}
{#if fractions.length === 0 && !loading}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center"
>No se encontraron resultados</Table.Cell
@@ -200,32 +242,21 @@
</Table.Row>
{/each}
{/if}
{#if loading}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page - 1)}
disabled={page === 1 || loading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {page} de {totalPages || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page + 1)}
disabled={page >= totalPages || loading}
>
Siguiente
</Button>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
<CanadianFractionDialog
bind:open={dialogOpen}

View File

@@ -0,0 +1,262 @@
<script lang="ts">
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;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>{fraction ? 'Editar' : 'Crear'} Fracción Histórica</Dialog.Title>
<Dialog.Description>
{fraction
? 'Modifica los detalles de la fracción seleccionada.'
: 'Ingresa los datos para la nueva fracción.'}
</Dialog.Description>
</Dialog.Header>
<div class="grid max-h-[70vh] gap-4 overflow-y-auto py-4 pr-2">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fraction">Fracción</Label>
<Input
id="fraction"
bind:value={historicalFractionCode}
placeholder="Ej. 01010101"
maxlength={8}
/>
</div>
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
<Input id="unit" bind:value={unitOfMeasureCode} placeholder="Ej. 01" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country">País</Label>
<Input id="country" bind:value={country} placeholder="Ej. MEX" />
</div>
<div class="space-y-2">
<Label for="type">Tipo</Label>
<Input id="type" bind:value={fractionType} placeholder="Ej. General" maxlength={7} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sector">Sector</Label>
<Input id="sector" bind:value={sector} placeholder="Sectores..." maxlength={5} />
</div>
<div class="space-y-2">
<Label for="by-log">Por Bitácora</Label>
<div class="flex items-center space-x-2 pt-2">
<Switch id="by-log" bind:checked={byLog} />
<span class="text-sm text-muted-foreground">{byLog ? 'Sí' : 'No'}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="import-tax">Tasa IGI (%)</Label>
<Input
id="import-tax"
type="number"
step="0.01"
bind:value={importTaxRate}
placeholder="0.00"
/>
</div>
<div class="space-y-2">
<Label for="export-tax">Tasa IGE (%)</Label>
<Input
id="export-tax"
type="number"
step="0.01"
bind:value={exportTaxRate}
placeholder="0.00"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="pub-date">Fecha Publicación</Label>
<Input id="pub-date" type="date" bind:value={publicationDate} />
</div>
<div class="space-y-2">
<Label for="end-date">Fecha Fin</Label>
<Input id="end-date" type="date" bind:value={endDate} />
</div>
</div>
<div class="grid grid-cols-2 gap-4 border-t pt-4">
<div class="flex items-center justify-between space-x-2">
<Label for="is-immex">IMMEX</Label>
<Switch id="is-immex" bind:checked={isImmex} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="normal-temp">Temp. Normal</Label>
<Switch id="normal-temp" bind:checked={normalTemporality} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between space-x-2">
<Label for="services-temp">Temp. Servicios</Label>
<Switch id="services-temp" bind:checked={servicesTemporality} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="certified-temp">Temp. Certificada</Label>
<Switch id="certified-temp" bind:checked={certifiedTemporality} />
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button onclick={handleSubmit} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Guardar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import {
getHistoricalFractions,
deleteHistoricalFraction,
type HistoricalFraction
} from '$lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions';
import * as Table from '$lib/components/ui/table';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Search, Loader2 } from 'lucide-svelte';
import { Search, Loader2, Plus, Pencil, Trash2 } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import HistoricalFractionDialog from './HistoricalFractionDialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
@@ -21,13 +23,31 @@
let pageSize = 50;
let searchTimeout: ReturnType<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
async function loadFractions(targetPage = 1) {
// Infinite scroll state
let hasMore = $state(true);
// Dialog state
let dialogOpen = $state(false);
let editingFraction = $state<HistoricalFraction | null>(null);
let deletingFractionId = $state<number | null>(null);
async function loadFractions(reset = false) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (loading) return;
loading = true;
page = targetPage;
if (reset) {
page = 1;
fractions = [];
hasMore = true;
} else {
page++;
}
try {
const response = await getHistoricalFractions(
@@ -36,25 +56,36 @@
page,
pageSize
);
fractions = response.items;
const newItems = response.items || [];
if (reset) {
fractions = newItems;
} else {
fractions = [...fractions, ...newItems];
}
totalItems = response.total;
totalPages = response.pages;
// Safer end-of-data detection
hasMore = newItems.length === pageSize && fractions.length < totalItems;
} catch (error) {
console.error('Error loading historical fractions:', error);
toast.error('Error al cargar fracciones históricas');
hasMore = false;
} finally {
loading = false;
}
}
function handleSearch() {
loadFractions(1);
loadFractions(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(1);
loadFractions(true);
}, 500);
}
@@ -65,36 +96,98 @@
}
}
onMount(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
function handleCreate() {
editingFraction = null;
dialogOpen = true;
}
function handleEdit(fraction: HistoricalFraction) {
editingFraction = fraction;
dialogOpen = true;
}
async function handleDelete(fraction: HistoricalFraction) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (!confirm(`¿Estás seguro de eliminar la fracción ${fraction.historical_fraction}?`)) return;
try {
deletingFractionId = fraction.id;
await deleteHistoricalFraction(companyId, fraction.id);
toast.success('Fracción eliminada correctamente');
loadFractions(true);
} catch (error) {
console.error('Error deleting historical fraction:', error);
toast.error('Error al eliminar la fracción');
} finally {
deletingFractionId = null;
}
}
function handleSuccess() {
loadFractions(true);
}
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && fractions.length > 0) {
loadFractions(false);
}
},
{ rootMargin: '100px' }
);
if (sentinel) observer.observe(sentinel);
}
// Removed onMount as we use $effect for company changes which covers initial load
// Reload when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId) {
untrack(() => loadFractions(true));
}
});
// Setup observer only when sentinel is available
$effect(() => {
if (companyStore.activeCompany?.id) {
loadFractions(1);
if (sentinel) {
setupObserver();
return () => observer?.disconnect();
}
});
</script>
<div class="space-y-4">
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<label for="search-fraction" class="mb-2 block text-sm font-medium">Fracción Histórica</label>
<div class="relative">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search-fraction"
type="text"
placeholder="Buscar fracción..."
class="pl-9"
bind:value={historicalFraction}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
<div class="flex flex-col items-end justify-between gap-4 md:flex-row">
<div class="flex max-w-2xl flex-1 items-end gap-4">
<div class="flex-1">
<label for="search-fraction" class="mb-2 block text-sm font-medium"
>Fracción Histórica</label
>
<div class="relative">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search-fraction"
type="text"
placeholder="Buscar fracción..."
class="pl-9"
bind:value={historicalFraction}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</div>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Nueva Fracción
</Button>
</div>
<div class="rounded-md border">
@@ -109,20 +202,13 @@
<Table.Head>Fecha Fin</Table.Head>
<Table.Head class="text-right">IGI</Table.Head>
<Table.Head class="text-right">IGE</Table.Head>
<Table.Head class="w-[100px]">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
{#if fractions.length === 0 && !loading}
<Table.Row>
<Table.Cell colspan={8} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{:else if fractions.length === 0}
<Table.Row>
<Table.Cell colspan={8} class="h-24 text-center"
<Table.Cell colspan={9} class="h-24 text-center"
>No se encontraron resultados</Table.Cell
>
</Table.Row>
@@ -145,33 +231,53 @@
>
<Table.Cell class="text-right">{fraction.import_tax_rate ?? '-'}</Table.Cell>
<Table.Cell class="text-right">{fraction.export_tax_rate ?? '-'}</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => handleEdit(fraction)}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => handleDelete(fraction)}
disabled={deletingFractionId === fraction.id}
>
{#if deletingFractionId === fraction.id}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{#if loading}
<Table.Row>
<Table.Cell colspan={9} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page - 1)}
disabled={page === 1 || loading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {page} de {totalPages || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => loadFractions(page + 1)}
disabled={page >= totalPages || loading}
>
Siguiente
</Button>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
<HistoricalFractionDialog
bind:open={dialogOpen}
fraction={editingFraction}
onSuccess={handleSuccess}
/>
</div>

View File

@@ -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<typeof setTimeout>;
let observer: IntersectionObserver;
let sentinel: HTMLDivElement;
// Infinite scroll state
let hasMore = $state(true);
let isFormDialogOpen = $state(false);
let selectedFraction = $state<TariffFraction | null>(null);
@@ -50,11 +55,19 @@
let showDeleteConfirm = $state(false);
let fractionToDelete = $state<TariffFraction | null>(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<string, any> = {};
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();
}
});
</script>
@@ -176,18 +219,7 @@
</TableRow>
</TableHeader>
<TableBody>
{#if isLoading}
<TableRow>
<TableCell
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
class="h-24 text-center"
>
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</TableCell>
</TableRow>
{:else if fractions.length === 0}
{#if fractions.length === 0 && !isLoading}
<TableRow>
<TableCell
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
@@ -232,32 +264,24 @@
</TableRow>
{/each}
{/if}
{#if isLoading}
<TableRow>
<TableCell
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
class="h-24 text-center"
>
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
</TableCell>
</TableRow>
{/if}
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
Anterior
</Button>
<div class="text-sm text-muted-foreground">
Página {currentPage} de {Math.ceil(totalFractions / pageSize) || 1}
</div>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(currentPage + 1)}
disabled={!fractions.length || fractions.length < pageSize || isLoading}
>
Siguiente
</Button>
</div>
<!-- Infinite Scroll Sentinel -->
<div bind:this={sentinel} class="h-4 w-full"></div>
</div>
<AlertDialog.Root bind:open={showDeleteConfirm}>