feat: Add Canadian fraction management dialog, implement debounced search for historical fractions and sectors, and enhance USA fraction search with description support.

This commit is contained in:
Galindo97
2026-02-17 11:50:50 -06:00
parent 2a0480a7f3
commit da43659eb3
7 changed files with 345 additions and 43 deletions

View File

@@ -145,12 +145,23 @@ class TariffFractionService:
try:
usa_service = FraccionesUSAService.get_instance()
search_term = None
search_description = None
if filters and filters.get("search"):
search_term = filters["search"]
term = filters["search"]
# Simple heuristic: if it looks like a code, use code search, else description
# FIX: Short numeric codes (e.g. "01") often fail strict 'fraccion' search.
# Treat them as description search for partial matching.
clean_term = term.replace(".", "")
if clean_term.isdigit() and len(clean_term) >= 4:
search_term = term
else:
search_description = term
# USA Service search signature: fraccion, skip, limit
# USA Service search signature: fraccion, descripcion, skip, limit
usa_items = await usa_service.search(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=limit
)
@@ -161,7 +172,9 @@ class TariffFractionService:
total += 1
return items, total
except Exception as e:
import traceback
logger.error(f"Error fetching USA fractions (API): {e}")
logger.error(traceback.format_exc())
# Return empty list on error as per requirement (since API is broken)
return [], 0

View File

@@ -21,6 +21,7 @@ class FraccionesUSAService(SitarAPIBaseService):
async def search(
self,
fraccion: Optional[str] = None,
descripcion: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesUSAResponse]:
@@ -28,6 +29,8 @@ class FraccionesUSAService(SitarAPIBaseService):
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if descripcion:
params["descripcion"] = descripcion
data = await self._make_request("GET", "api/v1/fracciones-usa/", params=params)
return [FraccionesUSAResponse(**item) for item in data]

View File

@@ -41,7 +41,15 @@ export async function getHistoricalFractions(
if (historicalFraction) params.append('historical_fraction', historicalFraction);
const response = await api.get<HistoricalFractionList>(`/v1/a76/general_catalogs/fractions/historical-tariff-fractions/?${params.toString()}`);
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;
}
if (!response.data) throw new Error('Error fetching historical fractions');
return response.data;
// Fallback
return response.data as unknown as HistoricalFractionList;
}

View File

@@ -18,6 +18,7 @@
let pageSize = 50;
let hasMore = true;
let total = 0;
let searchTimeout: ReturnType<typeof setTimeout>;
async function loadSectors(reset = false) {
if (loading || (!hasMore && !reset)) return;
@@ -52,6 +53,20 @@
loadSectors(true);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadSectors(true);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
function handleLoadMore() {
if (!loading && hasMore) {
page++;
@@ -78,15 +93,10 @@
placeholder="Buscar por clave o descripción..."
class="pl-8"
bind:value={searchTerm}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
<Button onclick={handleSearch} disabled={loading}>
{#if loading && page === 1}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Buscar
</Button>
</div>
</Card.Header>
<Card.Content>

View File

@@ -0,0 +1,180 @@
<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 { Textarea } from '$lib/components/ui/textarea';
import {
createCanadianFraction,
updateCanadianFraction,
type CanadianFraction,
type CanadianFractionCreate,
type CanadianFractionUpdate
} from '$lib/api/dashboard/general_catalogs/canadian';
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?: CanadianFraction | null;
onSuccess: () => void;
} = $props();
let isLoading = $state(false);
// Form fields
let fractionCode = $state('');
let countryCode = $state('');
let description = $state('');
let unitOfMeasure = $state('');
let adValorem = $state('');
// Load data on open/fraction change
$effect(() => {
if (open) {
if (fraction) {
// Edit mode
fractionCode = fraction.fraction;
countryCode = fraction.country_code;
description = fraction.description || '';
unitOfMeasure = fraction.unit_of_measure || '';
adValorem = fraction.ad_valorem?.toString() || '';
} else {
// Create mode - reset
fractionCode = '';
countryCode = '';
description = '';
unitOfMeasure = '';
adValorem = '';
}
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Validation
if (!fractionCode) {
toast.error('La fracción es requerida');
return;
}
if (!countryCode) {
toast.error('El código de país es requerido');
return;
}
isLoading = true;
try {
const adValoremNum = adValorem ? parseFloat(adValorem) : undefined;
if (adValorem && isNaN(adValoremNum!)) {
toast.error('El Ad Valorem debe ser un número válido');
isLoading = false;
return;
}
if (fraction) {
// Update
const updateData: CanadianFractionUpdate = {
fraction: fractionCode,
country_code: countryCode,
description,
unit_of_measure: unitOfMeasure || undefined,
ad_valorem: adValoremNum
};
await updateCanadianFraction(companyId, fraction.id, updateData);
toast.success('Fracción actualizada correctamente');
} else {
// Create
const createData: CanadianFractionCreate = {
fraction: fractionCode,
country_code: countryCode,
description,
unit_of_measure: unitOfMeasure || undefined,
ad_valorem: adValoremNum
};
await createCanadianFraction(companyId, createData);
toast.success('Fracción creada correctamente');
}
onSuccess();
open = false;
} catch (error) {
console.error('Error saving Canadian fraction:', error);
toast.error('Error al guardar la fracción');
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{fraction ? 'Editar' : 'Crear'} Fracción Canadiense</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 gap-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fraction">Fracción</Label>
<Input
id="fraction"
bind:value={fractionCode}
placeholder="Ej. 9999999999"
maxlength={13}
/>
</div>
<div class="space-y-2">
<Label for="country_code">País (Código ISO)</Label>
<Input id="country_code" bind:value={countryCode} placeholder="Ej. CAN" maxlength={3} />
</div>
</div>
<div class="space-y-2">
<Label for="description">Descripción</Label>
<Textarea
id="description"
bind:value={description}
placeholder="Descripción de la mercancía..."
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
<Input id="unit" bind:value={unitOfMeasure} placeholder="Ej. Kg" maxlength={5} />
</div>
<div class="space-y-2">
<Label for="ad_valorem">Ad Valorem (%)</Label>
<Input
id="ad_valorem"
type="number"
step="0.01"
bind:value={adValorem}
placeholder="0.00"
/>
</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

@@ -2,24 +2,33 @@
import { onMount } from 'svelte';
import {
getCanadianFractions,
deleteCanadianFraction,
type CanadianFraction
} from '$lib/api/dashboard/general_catalogs/canadian';
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 CanadianFractionDialog from './CanadianFractionDialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
let fractions: CanadianFraction[] = [];
let loading = false;
let searchQuery = '';
let page = 1;
let totalItems = 0;
let totalPages = 0;
let fractions = $state<CanadianFraction[]>([]);
let loading = $state(false);
let searchQuery = $state('');
let page = $state(1);
let totalItems = $state(0);
let totalPages = $state(0);
let pageSize = 50;
let searchTimeout: ReturnType<typeof setTimeout>;
// Dialog state
let dialogOpen = $state(false);
let editingFraction = $state<CanadianFraction | null>(null);
let deletingFractionId = $state<number | null>(null);
async function loadFractions(targetPage = 1) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -49,17 +58,52 @@
loadFractions(1);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(1);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
onMount(() => {
if (companyStore.activeCompany) {
loadFractions(1);
function handleCreate() {
editingFraction = null;
dialogOpen = true;
}
function handleEdit(fraction: CanadianFraction) {
editingFraction = fraction;
dialogOpen = true;
}
async function handleDelete(fraction: CanadianFraction) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (!confirm(`¿Estás seguro de eliminar la fracción ${fraction.fraction}?`)) return;
try {
deletingFractionId = fraction.id;
await deleteCanadianFraction(companyId, fraction.id);
toast.success('Fracción eliminada correctamente');
loadFractions(page);
} catch (error) {
console.error('Error deleting Canadian fraction:', error);
toast.error('Error al eliminar la fracción');
} finally {
deletingFractionId = null;
}
});
}
function handleSuccess() {
loadFractions(page);
}
$effect(() => {
if (companyStore.activeCompany?.id) {
@@ -69,24 +113,28 @@
</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">Buscar</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 por fracción o descripción..."
class="pl-9"
bind:value={searchQuery}
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">Buscar</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 por fracción o descripción..."
class="pl-9"
bind:value={searchQuery}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</div>
</div>
<div class="flex items-end">
<Button onclick={handleSearch} disabled={loading}>Buscar</Button>
</div>
<Button onclick={handleCreate}>
<Plus class="mr-2 h-4 w-4" />
Nueva Fracción
</Button>
</div>
<div class="rounded-md border">
@@ -98,12 +146,13 @@
<Table.Head>País</Table.Head>
<Table.Head>Unidad</Table.Head>
<Table.Head class="text-right">ADV</Table.Head>
<Table.Head class="w-[100px]">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center">
<Table.Cell colspan={6} class="h-24 text-center">
<div class="flex justify-center">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
@@ -111,7 +160,7 @@
</Table.Row>
{:else if fractions.length === 0}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center"
<Table.Cell colspan={6} class="h-24 text-center"
>No se encontraron resultados</Table.Cell
>
</Table.Row>
@@ -123,6 +172,31 @@
<Table.Cell>{fraction.country_code}</Table.Cell>
<Table.Cell>{fraction.unit_of_measure || '-'}</Table.Cell>
<Table.Cell class="text-right">{fraction.ad_valorem ?? '-'}</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}
@@ -152,4 +226,10 @@
Siguiente
</Button>
</div>
<CanadianFractionDialog
bind:open={dialogOpen}
fraction={editingFraction}
onSuccess={handleSuccess}
/>
</div>

View File

@@ -20,6 +20,8 @@
let totalPages = $state(0);
let pageSize = 50;
let searchTimeout: ReturnType<typeof setTimeout>;
async function loadFractions(targetPage = 1) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -49,8 +51,16 @@
loadFractions(1);
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
loadFractions(1);
}, 500);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
clearTimeout(searchTimeout);
handleSearch();
}
}
@@ -80,13 +90,11 @@
placeholder="Buscar fracción..."
class="pl-9"
bind:value={historicalFraction}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
/>
</div>
</div>
<div class="flex items-end">
<Button onclick={handleSearch} disabled={loading}>Buscar</Button>
</div>
</div>
<div class="rounded-md border">