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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user