feat: Add dialog for creating and editing historical tariff fractions and implement deterministic sorting for fraction listings.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user