Merge remote-tracking branch 'origin/development' into feature/Invoice-movements
# Conflicts: # backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py # backend/core/celery_app.py # frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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, 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';
|
||||
|
||||
let { title = 'Sectores' }: { title?: string } = $props();
|
||||
|
||||
let sectors = $state<Sector[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
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;
|
||||
loading = true;
|
||||
|
||||
if (reset) {
|
||||
page = 1;
|
||||
sectors = [];
|
||||
hasMore = true;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getSectors(page, pageSize, searchTerm || undefined);
|
||||
|
||||
const newItems = response.items || [];
|
||||
if (reset) {
|
||||
sectors = newItems;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadSectors(true);
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadSectors(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
clearTimeout(searchTimeout);
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Initial load
|
||||
$effect(() => {
|
||||
untrack(() => loadSectors(true));
|
||||
});
|
||||
|
||||
// Setup observer only when sentinel is available
|
||||
$effect(() => {
|
||||
if (sentinel) {
|
||||
setupObserver();
|
||||
return () => observer?.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-bold tracking-tight">{title}</h2>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por clave o descripción..."
|
||||
class="pl-8"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleSearchInput}
|
||||
onkeydown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="text-right">Estatus</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if loading && page === 1}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} 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 sectors.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="h-24 text-center">
|
||||
No se encontraron sectores.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each sectors as sector}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{sector.key}</Table.Cell>
|
||||
<Table.Cell>{sector.description}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if sector.authorized}
|
||||
<Badge variant="default">Autorizado</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary">No Autorizado</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</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>
|
||||
|
||||
<div class="mt-4 flex flex-col items-center gap-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Mostrando {sectors.length} de {total} registros
|
||||
</div>
|
||||
<!-- Infinite Scroll Sentinel -->
|
||||
<div bind:this={sentinel} class="h-4 w-full"></div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } 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, 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 = $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>;
|
||||
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(reset = false) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
if (loading) return;
|
||||
|
||||
loading = true;
|
||||
|
||||
if (reset) {
|
||||
page = 1;
|
||||
fractions = [];
|
||||
hasMore = true;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getCanadianFractions(
|
||||
companyId,
|
||||
page,
|
||||
pageSize,
|
||||
searchQuery || undefined
|
||||
);
|
||||
|
||||
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(true);
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadFractions(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
clearTimeout(searchTimeout);
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
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(true);
|
||||
} catch (error) {
|
||||
console.error('Error deleting Canadian 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
|
||||
|
||||
$effect(() => {
|
||||
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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
<Button onclick={handleCreate}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva Fracción
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Fracción</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<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 fractions.length === 0 && !loading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="h-24 text-center"
|
||||
>No se encontraron resultados</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each fractions as fraction}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{fraction.fraction}</Table.Cell>
|
||||
<Table.Cell>{fraction.description || '-'}</Table.Cell>
|
||||
<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}
|
||||
{#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>
|
||||
|
||||
<!-- Infinite Scroll Sentinel -->
|
||||
<div bind:this={sentinel} class="h-4 w-full"></div>
|
||||
|
||||
<CanadianFractionDialog
|
||||
bind:open={dialogOpen}
|
||||
fraction={editingFraction}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
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, Plus, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import HistoricalFractionDialog from './HistoricalFractionDialog.svelte';
|
||||
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let fractions = $state<HistoricalFraction[]>([]);
|
||||
let loading = $state(false);
|
||||
let historicalFraction = $state('');
|
||||
let page = $state(1);
|
||||
let totalItems = $state(0);
|
||||
let totalPages = $state(0);
|
||||
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<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;
|
||||
|
||||
if (reset) {
|
||||
page = 1;
|
||||
fractions = [];
|
||||
hasMore = true;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getHistoricalFractions(
|
||||
companyId,
|
||||
historicalFraction || undefined,
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
|
||||
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(true);
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadFractions(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
clearTimeout(searchTimeout);
|
||||
handleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
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 (sentinel) {
|
||||
setupObserver();
|
||||
return () => observer?.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Fracción</Table.Head>
|
||||
<Table.Head>Tipo</Table.Head>
|
||||
<Table.Head>UM</Table.Head>
|
||||
<Table.Head>País</Table.Head>
|
||||
<Table.Head>Fecha Pub.</Table.Head>
|
||||
<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 fractions.length === 0 && !loading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={9} class="h-24 text-center"
|
||||
>No se encontraron resultados</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each fractions as fraction}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{fraction.historical_fraction}</Table.Cell>
|
||||
<Table.Cell>{fraction.fraction_type || '-'}</Table.Cell>
|
||||
<Table.Cell>{fraction.unit_of_measure_code || '-'}</Table.Cell>
|
||||
<Table.Cell>{fraction.country || '-'}</Table.Cell>
|
||||
<Table.Cell
|
||||
>{fraction.publication_date
|
||||
? new Date(fraction.publication_date).toLocaleDateString()
|
||||
: '-'}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
>{fraction.end_date
|
||||
? new Date(fraction.end_date).toLocaleDateString()
|
||||
: '-'}</Table.Cell
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Infinite Scroll Sentinel -->
|
||||
<div bind:this={sentinel} class="h-4 w-full"></div>
|
||||
|
||||
<HistoricalFractionDialog
|
||||
bind:open={dialogOpen}
|
||||
fraction={editingFraction}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,198 @@
|
||||
<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 {
|
||||
createTariffFraction,
|
||||
updateTariffFraction,
|
||||
type TariffFraction,
|
||||
type TariffFractionCreate,
|
||||
type TariffFractionUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/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.
|
||||
catalog = 'mex',
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
fraction?: TariffFraction | null;
|
||||
catalog?: string;
|
||||
onSuccess: () => void;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Form fields
|
||||
let code = $state('');
|
||||
let fractionFormatted = $state('');
|
||||
let description = $state('');
|
||||
let nico = $state('');
|
||||
let umt = $state('');
|
||||
let adv_impo = $state('');
|
||||
let adv_expo = $state('');
|
||||
let um_code = $state(''); // New field for unit code
|
||||
|
||||
// Load data on open/fraction change
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (fraction) {
|
||||
// Edit mode
|
||||
code = fraction.code;
|
||||
fractionFormatted = fraction.fraction;
|
||||
description = fraction.description || '';
|
||||
nico = fraction.nico || '';
|
||||
umt = fraction.umt || '';
|
||||
adv_impo = fraction.adv_impo || '';
|
||||
adv_expo = fraction.adv_expo || '';
|
||||
um_code = fraction.um_code || '';
|
||||
} else {
|
||||
// Create mode - reset
|
||||
code = '';
|
||||
fractionFormatted = '';
|
||||
description = '';
|
||||
nico = '';
|
||||
umt = '';
|
||||
adv_impo = '';
|
||||
adv_expo = '';
|
||||
um_code = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
if (fraction) {
|
||||
// Update
|
||||
const updateData: TariffFractionUpdate = {
|
||||
fraction: fractionFormatted,
|
||||
description,
|
||||
nico: catalog === 'mex' ? nico : null,
|
||||
umt,
|
||||
adv_impo,
|
||||
adv_expo
|
||||
};
|
||||
await updateTariffFraction(fraction.id, updateData, companyId, catalog);
|
||||
toast.success('Fracción actualizada correctamente');
|
||||
} else {
|
||||
// Create
|
||||
const createData: TariffFractionCreate = {
|
||||
code,
|
||||
fraction: fractionFormatted,
|
||||
description,
|
||||
nico: catalog === 'mex' ? nico : null,
|
||||
umt,
|
||||
adv_impo,
|
||||
adv_expo
|
||||
};
|
||||
await createTariffFraction(createData, companyId, catalog);
|
||||
toast.success('Fracción creada correctamente');
|
||||
}
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Error saving 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 Arancelaria {catalog === 'usa'
|
||||
? '(USA)'
|
||||
: ''}</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="code">Clave / Código {catalog === 'mex' ? '(Sin puntos)' : ''}</Label>
|
||||
<Input id="code" bind:value={code} disabled={!!fraction} placeholder="Ej. 01012101" />
|
||||
{#if fraction}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
El código no se puede modificar una vez creado.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fraction">Fracción {catalog === 'mex' ? '(Con puntos)' : ''}</Label>
|
||||
<Input id="fraction" bind:value={fractionFormatted} placeholder="Ej. 0101.21.01" />
|
||||
</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>
|
||||
|
||||
{#if catalog === 'mex'}
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="nico">NICO</Label>
|
||||
<Input id="nico" bind:value={nico} placeholder="Ej. 00" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="um_code">Clave U.M.</Label>
|
||||
<Input id="um_code" bind:value={um_code} placeholder="Ej. 06" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="umt">U.M.T</Label>
|
||||
<Input id="umt" bind:value={umt} placeholder="Ej. Kg" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="umt">Unidad de Medida</Label>
|
||||
<Input id="umt" bind:value={umt} placeholder="Unit" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="adv_impo">Adv. Impo</Label>
|
||||
<Input id="adv_impo" bind:value={adv_impo} placeholder="Ej. Ex." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="adv_expo">Adv. Expo</Label>
|
||||
<Input id="adv_expo" bind:value={adv_expo} placeholder="Ej. Ex." />
|
||||
</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>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table';
|
||||
import { Loader2, Plus, Search, Trash2, Edit } from 'lucide-svelte';
|
||||
import {
|
||||
getTariffFractions,
|
||||
deleteTariffFraction,
|
||||
type TariffFraction
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
|
||||
import { companyStore } from '$lib/stores/company.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';
|
||||
|
||||
let {
|
||||
title = 'Fracciones Arancelarias',
|
||||
catalog = 'mex', // 'mex' or 'usa'
|
||||
levelFilter = null, // null or number
|
||||
readOnly = false
|
||||
}: {
|
||||
title?: string;
|
||||
catalog?: string;
|
||||
levelFilter?: number | null;
|
||||
readOnly?: boolean;
|
||||
} = $props();
|
||||
|
||||
let fractions = $state<TariffFraction[]>([]);
|
||||
let totalFractions = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let pageSize = 50;
|
||||
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);
|
||||
let isManageMode = $state(false); // If true, opens form in edit mode
|
||||
|
||||
// Delete confirmation
|
||||
let showDeleteConfirm = $state(false);
|
||||
let fractionToDelete = $state<TariffFraction | null>(null);
|
||||
|
||||
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;
|
||||
if (levelFilter !== null) filters.level = levelFilter;
|
||||
filters.catalog = catalog;
|
||||
|
||||
const response = await getTariffFractions(currentPage, pageSize, companyId, filters);
|
||||
|
||||
if (response.data) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadFractions(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
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
|
||||
isFormDialogOpen = true;
|
||||
}
|
||||
|
||||
function openEditDialog(fraction: TariffFraction) {
|
||||
selectedFraction = fraction;
|
||||
isManageMode = true; // Edit mode
|
||||
isFormDialogOpen = true;
|
||||
}
|
||||
|
||||
function confirmDelete(fraction: TariffFraction) {
|
||||
fractionToDelete = fraction;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!fractionToDelete || !companyStore.activeCompany?.id) return;
|
||||
|
||||
try {
|
||||
// Note: Delete might allow deleting items from source API if allowed,
|
||||
// or just local overrides. Assuming Service handles logic.
|
||||
await deleteTariffFraction(fractionToDelete.id, companyStore.activeCompany.id, catalog);
|
||||
toast.success('Fracción eliminada correctamente');
|
||||
loadFractions(true);
|
||||
} catch (error) {
|
||||
console.error('Error deleting fraction:', error);
|
||||
toast.error('Error al eliminar la fracción. Puede que esté en uso.');
|
||||
} finally {
|
||||
showDeleteConfirm = false;
|
||||
fractionToDelete = null;
|
||||
}
|
||||
}
|
||||
// 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 (sentinel) {
|
||||
setupObserver();
|
||||
return () => observer?.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{title}</h2>
|
||||
{#if !readOnly}
|
||||
<Button onclick={openCreateDialog}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva Fracción
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="relative max-w-sm flex-1">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Buscar..." class="pl-8" bind:value={search} oninput={handleSearchInput} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Clave</TableHead>
|
||||
<TableHead>Fracción</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
{#if catalog === 'mex'}
|
||||
<TableHead>NICO</TableHead>
|
||||
<TableHead>U.M.T</TableHead>
|
||||
{:else}
|
||||
<TableHead>Unidad</TableHead>
|
||||
{/if}
|
||||
<TableHead>Adv. Impo</TableHead>
|
||||
<TableHead>Adv. Expo</TableHead>
|
||||
{#if !readOnly}
|
||||
<TableHead class="w-[100px]">Acciones</TableHead>
|
||||
{/if}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if fractions.length === 0 && !isLoading}
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colspan={catalog === 'mex' ? (readOnly ? 7 : 8) : readOnly ? 6 : 7}
|
||||
class="h-24 text-center"
|
||||
>
|
||||
No se encontraron resultados
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
{#each fractions as fraction}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono">{fraction.um_code || fraction.code}</TableCell>
|
||||
<TableCell class="font-medium">{fraction.fraction}</TableCell>
|
||||
<TableCell class="max-w-md truncate" title={fraction.description}>
|
||||
{fraction.description}
|
||||
</TableCell>
|
||||
{#if catalog === 'mex'}
|
||||
<TableCell>{fraction.nico || '-'}</TableCell>
|
||||
<TableCell>{fraction.umt || '-'}</TableCell>
|
||||
{:else}
|
||||
<TableCell>{fraction.umt || '-'}</TableCell>
|
||||
{/if}
|
||||
<TableCell>{fraction.adv_impo || '-'}</TableCell>
|
||||
<TableCell>{fraction.adv_expo || '-'}</TableCell>
|
||||
{#if !readOnly}
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onclick={() => openEditDialog(fraction)}>
|
||||
<Edit class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => confirmDelete(fraction)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<!-- Infinite Scroll Sentinel -->
|
||||
<div bind:this={sentinel} class="h-4 w-full"></div>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteConfirm}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Esta acción no se puede deshacer. Se eliminará la fracción arancelaria permanentemente.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
Eliminar
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
{#if isFormDialogOpen}
|
||||
<TariffFractionFormDialog
|
||||
bind:open={isFormDialogOpen}
|
||||
fraction={selectedFraction}
|
||||
{catalog}
|
||||
onSuccess={() => {
|
||||
loadFractions();
|
||||
isFormDialogOpen = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Search, Loader2 } from 'lucide-svelte';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
regimen = 'Temporal',
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
regimen: string;
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
} = $props();
|
||||
|
||||
let invoices = $state<Invoice[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
|
||||
async function searchInvoices() {
|
||||
if (!companyStore.activeCompany) return;
|
||||
loading = true;
|
||||
try {
|
||||
let filters: any = {
|
||||
operation_type: 'imp',
|
||||
invoice_number: searchTerm
|
||||
};
|
||||
|
||||
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
|
||||
filters.invoice_type = 'TEM';
|
||||
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
filters.invoice_type = 'DEF';
|
||||
}
|
||||
|
||||
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
|
||||
if (res.data) {
|
||||
invoices = res.data.items || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error searching invoices:', e);
|
||||
toast.error('Error al buscar facturas');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(invoice: Invoice) {
|
||||
onSelect(invoice);
|
||||
open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
searchInvoices();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Factura ({regimen})</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por número..."
|
||||
class="h-9 pl-8"
|
||||
bind:value={searchTerm}
|
||||
onkeydown={(e) => e.key === 'Enter' && searchInvoices()}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onclick={searchInvoices} disabled={loading}>
|
||||
{#if loading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Buscar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-y-auto rounded-md border text-xs">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-muted/90 text-left backdrop-blur-sm">
|
||||
<tr>
|
||||
<th class="p-3 font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>Número de Factura</th
|
||||
>
|
||||
<th class="p-3 font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>Pedimento</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#if invoices.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="p-12 text-center text-muted-foreground">
|
||||
{#if loading}
|
||||
Buscando facturas...
|
||||
{:else}
|
||||
No se encontraron resultados
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each invoices as invoice}
|
||||
<tr
|
||||
class="group cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onclick={() => handleSelect(invoice)}
|
||||
>
|
||||
<td
|
||||
class="p-3 font-mono font-bold text-primary transition-colors group-hover:text-primary/80"
|
||||
>
|
||||
{invoice.invoice_number}
|
||||
</td>
|
||||
<td class="max-w-[400px] truncate p-3 text-muted-foreground italic">
|
||||
{invoice.compliance_mx?.pedimento_r1 ||
|
||||
invoice.compliance_mx?.pedimento_id ||
|
||||
'-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,13 +5,13 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Folder } from 'lucide-svelte';
|
||||
import PartNumberDialog from './part-number-dialog.svelte';
|
||||
import type { LineItem, LineDescriptions } from '$lib/api/dashboard/a76/items';
|
||||
import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items';
|
||||
|
||||
let {
|
||||
lineItem = $bindable(),
|
||||
descriptions = $bindable()
|
||||
}: {
|
||||
lineItem: LineItem;
|
||||
lineItem: Partial<Item>;
|
||||
descriptions: LineDescriptions;
|
||||
} = $props();
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
lineItem.fa_data.contains_subitems = val === 'si';
|
||||
}
|
||||
|
||||
// Helper for subitem_number binding
|
||||
let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0);
|
||||
function setSubitemNumber(val: number) {
|
||||
if (!lineItem.fa_data) lineItem.fa_data = {};
|
||||
lineItem.fa_data.subitem_number = val;
|
||||
}
|
||||
|
||||
function handlePartSelect(part: any) {
|
||||
lineItem.part_number = part.id;
|
||||
// Store part number for display
|
||||
@@ -65,23 +72,36 @@
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="border rounded-md p-2 space-y-2">
|
||||
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Contains Sub-Items</legend>
|
||||
<RadioGroup.Root
|
||||
value={containsSubPartidasValue}
|
||||
onValueChange={setContinueSubPartidas}
|
||||
class="flex gap-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="si" id="continue_si" />
|
||||
<Label for="continue_si" class="text-xs font-normal">Yes</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="no" id="continue_no" />
|
||||
<Label for="continue_no" class="text-xs font-normal">No</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
{#if isSubPartidaValue === 'partida'}
|
||||
<fieldset class="border rounded-md p-2 space-y-2">
|
||||
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Contains Sub-Items</legend>
|
||||
<RadioGroup.Root
|
||||
value={containsSubPartidasValue}
|
||||
onValueChange={setContinueSubPartidas}
|
||||
class="flex gap-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="si" id="continue_si" />
|
||||
<Label for="continue_si" class="text-xs font-normal">Yes</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="no" id="continue_no" />
|
||||
<Label for="continue_no" class="text-xs font-normal">No</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</fieldset>
|
||||
{:else if isSubPartidaValue === 'subpartida'}
|
||||
<fieldset class="border rounded-md p-1">
|
||||
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Main Item Number</legend>
|
||||
<Input
|
||||
id="subitem_number"
|
||||
type="number"
|
||||
value={subitemNumber}
|
||||
oninput={(e) => setSubitemNumber(e.currentTarget.valueAsNumber || 0)}
|
||||
class="h-7 text-xs"
|
||||
placeholder="Enter main item number"
|
||||
/>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Descriptions -->
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
} = $props();
|
||||
|
||||
// Acceso directo a la primera línea para evitar repeticiones en el HTML
|
||||
let line = $derived(editingItem.lines?.[0]);
|
||||
let line = $derived(editingItem);
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={open}>
|
||||
@@ -77,10 +77,11 @@
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<MainData
|
||||
bind:lineItem={editingItem.lines![0]}
|
||||
bind:quantities={editingItem.lines![0].quantity!}
|
||||
bind:financials={editingItem.lines![0].financial!}
|
||||
bind:customs={editingItem.lines![0].customs!}
|
||||
bind:lineItem={editingItem}
|
||||
bind:quantities={editingItem.quantity!}
|
||||
bind:financials={editingItem.financial!}
|
||||
bind:customs={editingItem.customs!}
|
||||
{invoice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,8 +94,8 @@
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ItemConfiguration
|
||||
bind:lineItem={editingItem.lines![0]}
|
||||
bind:descriptions={editingItem.lines![0].description!}
|
||||
bind:lineItem={editingItem}
|
||||
bind:descriptions={editingItem.description!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,36 +126,38 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<PackagesSection
|
||||
bind:item={editingItem}
|
||||
bind:lineItem={editingItem.lines![0]}
|
||||
bind:descriptions={editingItem.lines![0].description!}
|
||||
bind:customs={editingItem.lines![0].customs!}
|
||||
bind:quantities={editingItem.lines![0].quantity!}
|
||||
bind:lineItem={editingItem}
|
||||
bind:descriptions={editingItem.description!}
|
||||
bind:customs={editingItem.customs!}
|
||||
bind:quantities={editingItem.quantity!}
|
||||
invoice={invoice}
|
||||
/>
|
||||
<SummarySection
|
||||
bind:financials={editingItem.lines![0].financial!}
|
||||
bind:quantities={editingItem.lines![0].quantity!}
|
||||
bind:financials={editingItem.financial!}
|
||||
bind:quantities={editingItem.quantity!}
|
||||
lineItem={editingItem}
|
||||
{invoice}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="continuacion" class="m-0 focus-visible:outline-none">
|
||||
<TabContinuation
|
||||
bind:lineItem={editingItem.lines![0]}
|
||||
bind:descriptions={editingItem.lines![0].description!}
|
||||
bind:lineItem={editingItem}
|
||||
bind:descriptions={editingItem.description!}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="series" class="m-0 focus-visible:outline-none">
|
||||
<TabSeries bind:descriptions={editingItem.lines![0].description!} />
|
||||
<TabSeries bind:descriptions={editingItem.description!} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="etiquetado" class="m-0 focus-visible:outline-none">
|
||||
<TabLabeling bind:descriptions={editingItem.lines![0].description!} />
|
||||
<TabLabeling bind:descriptions={editingItem.description!} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="identificadores" class="m-0 focus-visible:outline-none">
|
||||
<TabIdentifiers bind:lineItem={editingItem.lines![0]} />
|
||||
<TabIdentifiers bind:lineItem={editingItem} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
@@ -7,19 +7,22 @@
|
||||
import CountryDialog from './country-dialog.svelte';
|
||||
import TariffFractionDialog from './tariff-fraction-dialog.svelte';
|
||||
import ClassDialog from './class-dialog.svelte';
|
||||
import type { LineItem, LineQuantities, LineFinancials, LineCustoms } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import type { Item, LineQuantities, LineFinancials, LineCustoms } from '$lib/api/dashboard/a76/items';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
lineItem = $bindable(),
|
||||
quantities = $bindable(),
|
||||
financials = $bindable(),
|
||||
customs = $bindable()
|
||||
customs = $bindable(),
|
||||
invoice
|
||||
}: {
|
||||
lineItem: LineItem;
|
||||
lineItem: Partial<Item>;
|
||||
quantities: LineQuantities;
|
||||
financials: LineFinancials;
|
||||
customs: LineCustoms;
|
||||
invoice: Invoice | null;
|
||||
} = $props();
|
||||
|
||||
let showClassDialog = $state(false);
|
||||
@@ -27,16 +30,26 @@
|
||||
let showCountryDialog = $state(false);
|
||||
let showFractionDialog = $state(false);
|
||||
|
||||
// Format fraction with dots for display
|
||||
let fractionDisplay = $derived(() => {
|
||||
const frac = customs.fraction;
|
||||
if (!frac) return '';
|
||||
// If already has dots, return as is
|
||||
if (frac.includes('.')) return frac;
|
||||
// Format 8-digit fraction as XX.XX.XX.XX
|
||||
if (frac.length === 8) {
|
||||
return `${frac.slice(0, 4)}.${frac.slice(4, 6)}.${frac.slice(6, 8)}`;
|
||||
}
|
||||
// Format 10-digit fraction as XX.XX.XX.XXXX
|
||||
if (frac.length === 10) {
|
||||
return `${frac.slice(0, 4)}.${frac.slice(4, 6)}.${frac.slice(6, 8)}.${frac.slice(8, 10)}`;
|
||||
}
|
||||
return frac;
|
||||
});
|
||||
|
||||
// Track previous class_id to detect changes
|
||||
let previousClassId = $state<number | undefined>(undefined);
|
||||
|
||||
// Initialize from existing data
|
||||
$effect(() => {
|
||||
if (lineItem.class_code) {
|
||||
// Do nothing, it's already set
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for class_id changes and update descriptions automatically
|
||||
$effect(() => {
|
||||
const currentClassId = lineItem.class_id;
|
||||
@@ -97,10 +110,10 @@
|
||||
(lineItem as any).description.description_english = classItem.description_en;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleUnitSelect(unit: any) {
|
||||
lineItem.unit_of_measure = unit.id;
|
||||
lineItem.unit_of_measure = unit.id;
|
||||
// Store unit code for display
|
||||
(lineItem as any).unit_code = unit.code;
|
||||
(lineItem as any).unit_description = unit.description || unit.description_en;
|
||||
@@ -112,9 +125,50 @@
|
||||
}
|
||||
|
||||
function handleFractionSelect(fraction: any) {
|
||||
customs.fraction = fraction.fraction;
|
||||
// Save without dots for backend, concatenating fraction + nico (8 + 2 = 10 chars)
|
||||
const fractionBase = fraction.fraction?.replace(/\./g, '') || '';
|
||||
const nico = fraction.nico || '';
|
||||
customs.fraction = fractionBase + nico;
|
||||
(customs as any).fraction_description = fraction.description;
|
||||
}
|
||||
|
||||
// Auto-fetch historical tariff rate when fraction, country, type, and date are available
|
||||
$effect(() => {
|
||||
const fraction = customs.fraction?.replace(/\./g, '') || '';
|
||||
const nico = fraction.substring(8, 10);
|
||||
const fractionType = customs.fraction_type;
|
||||
const invoiceDate = invoice?.invoice_date;
|
||||
|
||||
// Only fetch if all required fields are present and fraction has at least 8 chars
|
||||
if (fraction && fraction.length >= 8 && nico && fractionType && invoiceDate) {
|
||||
const historicalFraction = fraction.substring(0, 8);
|
||||
const params = new URLSearchParams({
|
||||
historical_fraction: historicalFraction,
|
||||
nico: nico,
|
||||
fraction_type: fractionType,
|
||||
invoice_date: invoiceDate
|
||||
});
|
||||
|
||||
fetch(`/api-sveltekit/historical-tariff-fractions/rate?${params}`)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error('Failed to fetch tariff rate');
|
||||
})
|
||||
.then(data => {
|
||||
if (data.found && data.rate !== null) {
|
||||
customs.rate = data.rate;
|
||||
} else {
|
||||
customs.rate = '0';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching historical tariff rate:', error);
|
||||
// Keep current value on error
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ClassDialog bind:open={showClassDialog} onSelect={handleClassSelect} />
|
||||
@@ -186,7 +240,7 @@
|
||||
<div class="space-y-1">
|
||||
<Label for="costo_unitario" class="text-xs font-medium">Unit Cost: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="costo_unitario" type="number" step="0.00000001" min="0" bind:value={financials.unit_cost_usd} class="h-8 text-xs text-right flex-1" />
|
||||
<Input id="costo_unitario" type="number" step="0.00000001" min="0" bind:value={financials.unit_cost_capture} class="h-8 text-xs text-right flex-1" />
|
||||
<span class="text-xs text-zinc-900 dark:text-zinc-100 font-semibold">USD</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +250,7 @@
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraccion"
|
||||
value={customs.fraction || ''}
|
||||
value={fractionDisplay()}
|
||||
readonly
|
||||
class="h-8 text-xs text-center flex-1 bg-muted cursor-pointer"
|
||||
placeholder="Selecciona fracción"
|
||||
@@ -233,17 +287,13 @@
|
||||
>
|
||||
<Folder class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if (customs as any).origin_country_name}
|
||||
<p class="text-xs text-muted-foreground truncate">{(customs as any).origin_country_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-6">
|
||||
<div class="space-y-1">
|
||||
<Label for="tipo_tarifa" class="text-xs font-medium">Tariff Type: <span class="text-red-500">*</span></Label>
|
||||
<select id="tipo_tarifa" bind:value={customs.fraction_type} class="flex h-8 w-auto min-w-[140px] rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background">
|
||||
<option value={undefined}>Selecciona...</option>
|
||||
<select id="tipo_tarifa" bind:value={customs.fraction_type} class="flex h-8 w-auto min-w-[140px] rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background">
|
||||
<option value="GENERAL">GENERAL</option>
|
||||
<option value="PROSEC">PROSEC</option>
|
||||
<option value="ALADI">ALADI</option>
|
||||
@@ -255,7 +305,7 @@
|
||||
<div class="flex items-center gap-2 h-8 pb-0.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">Advalorem:</span>
|
||||
<span class="text-xs text-zinc-900 dark:text-zinc-100 font-semibold">
|
||||
{customs.advalorem || '0'}
|
||||
{customs.rate || '0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { LineFinancials, LineQuantities } from '$lib/api/dashboard/a76/items';
|
||||
import type { LineFinancials, LineQuantities, Item } from '$lib/api/dashboard/a76/items';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import type { Part } from '$lib/api/dashboard/a76/parts';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let { financials = $bindable(), quantities = $bindable() }: { financials: LineFinancials; quantities: LineQuantities } = $props();
|
||||
let {
|
||||
financials = $bindable(),
|
||||
quantities = $bindable(),
|
||||
lineItem,
|
||||
invoice
|
||||
}: {
|
||||
financials: LineFinancials;
|
||||
quantities: LineQuantities;
|
||||
lineItem?: Partial<Item>;
|
||||
invoice?: Invoice | null;
|
||||
} = $props();
|
||||
|
||||
// Helper function to safely format numbers
|
||||
function formatNumber(value: any, decimals: number = 8): string {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? '0.00000000' : num.toFixed(decimals);
|
||||
}
|
||||
|
||||
// ========== ESTRUCTURA DE COMPARACIÓN DE MONEDAS ==========
|
||||
|
||||
// Extraer las monedas del invoice y del part
|
||||
let invoiceCurrency = $derived(invoice?.financials?.currency_type);
|
||||
|
||||
// Effect para actualizar los costos unitarios en diferentes monedas cuando cambie unit_cost_capture
|
||||
$effect(() => {
|
||||
const exchangeRate = invoice?.financials?.exchange_rate || 1;
|
||||
const unitCostCapture = financials.unit_cost_capture || 1;
|
||||
|
||||
// Calcular unit_cost_usd y unit_cost_mxn basado en la moneda del invoice
|
||||
if (invoiceCurrency === 'USD') {
|
||||
financials.unit_cost_usd = unitCostCapture;
|
||||
financials.unit_cost_mxn = unitCostCapture * exchangeRate;
|
||||
} else if (invoiceCurrency === 'MXN') {
|
||||
financials.unit_cost_mxn = unitCostCapture;
|
||||
financials.unit_cost_usd = unitCostCapture / exchangeRate;
|
||||
} else {
|
||||
// Por defecto, si no hay moneda definida, asumir USD
|
||||
financials.unit_cost_usd = unitCostCapture;
|
||||
financials.unit_cost_mxn = unitCostCapture * exchangeRate;
|
||||
}
|
||||
});
|
||||
|
||||
let calculatedValueCapture = $derived.by(() => {
|
||||
const quantity = quantities.quantity || 0;
|
||||
const unitCost = financials.unit_cost_capture;
|
||||
|
||||
// Calcular el valor total (costo unitario * cantidad)
|
||||
return (unitCost || 0) * quantity;
|
||||
});
|
||||
|
||||
// Effect para actualizar los valores en diferentes monedas cuando cambie calculatedValueCapture
|
||||
$effect(() => {
|
||||
const exchangeRate = invoice?.financials?.exchange_rate || 1;
|
||||
const valueCapture = calculatedValueCapture;
|
||||
|
||||
// value_mc es el valor en la moneda de captura (invoice currency)
|
||||
financials.value_mc = valueCapture;
|
||||
|
||||
// Calcular value_usd y value_mxn basado en la moneda del invoice
|
||||
if (invoiceCurrency === 'USD') {
|
||||
financials.value_usd = valueCapture;
|
||||
financials.value_mxn = valueCapture * exchangeRate;
|
||||
} else if (invoiceCurrency === 'MXN') {
|
||||
financials.value_mxn = valueCapture;
|
||||
financials.value_usd = valueCapture / exchangeRate;
|
||||
} else {
|
||||
// Por defecto, si no hay moneda definida, asumir USD
|
||||
financials.value_usd = valueCapture;
|
||||
financials.value_mxn = valueCapture * exchangeRate;
|
||||
}
|
||||
});
|
||||
|
||||
// ========== FIN ESTRUCTURA DE COMPARACIÓN ==========
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -27,7 +96,7 @@
|
||||
<div class="font-semibold">WEIGHTS (Pounds)</div>
|
||||
<div>Net: <span class="text-gray-900 dark:text-gray-100">{formatNumber(quantities.net_weight)}</span></div>
|
||||
<div><span class="text-gray-900 dark:text-gray-100">0.00000000</span></div>
|
||||
<div>Whole: <span class="text-gray-900 dark:text-gray-100">{formatNumber(quantities.gross_weight)}</span></div>
|
||||
<div>Gross: <span class="text-gray-900 dark:text-gray-100">{formatNumber(quantities.gross_weight)}</span></div>
|
||||
<div><span class="text-gray-900 dark:text-gray-100">0.00000000</span></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -43,9 +112,11 @@
|
||||
<div><span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.unit_cost_mxn)}</span></div>
|
||||
<div>Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.value_usd)}</span></div>
|
||||
<div><span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.value_mxn)}</span></div>
|
||||
<div class="text-xs">Capture Cost: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.unit_cost_capture)}</span> <span class="text-gray-900 dark:text-gray-100">USD</span></div>
|
||||
<div class="text-xs">Capture Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.value_usd)}</span> <span class="text-gray-900 dark:text-gray-100">USD</span></div>
|
||||
<div class="text-xs">Customs Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.customs_value_usd)}</span> <span class="text-gray-900 dark:text-gray-100">USD</span></div>
|
||||
<div class="text-xs">Customs Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.customs_value_usd)}</span></div>
|
||||
<div class="text-xs">Customs Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.customs_value_mxn)}</span></div>
|
||||
<div class="text-xs">Capture Cost: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.unit_cost_capture)}</span> <span class="text-gray-900 dark:text-gray-100">{invoiceCurrency}</span></div>
|
||||
<div></div>
|
||||
<div class="text-xs">Capture Value: <span class="text-gray-900 dark:text-gray-100">{formatNumber(financials.value_mc)}</span> <span class="text-gray-900 dark:text-gray-100">{invoiceCurrency}</span></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Folder } from 'lucide-svelte';
|
||||
import type { LineItem, LineDescriptions } from '$lib/api/dashboard/a76/items';
|
||||
import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items';
|
||||
import PaymentMethodDialog from './payment-method-dialog.svelte';
|
||||
|
||||
let {
|
||||
lineItem = $bindable(),
|
||||
descriptions = $bindable()
|
||||
}: {
|
||||
lineItem: LineItem;
|
||||
lineItem: Partial<Item>;
|
||||
descriptions: LineDescriptions;
|
||||
} = $props();
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
lineItem.has_certificate = val === 'si';
|
||||
}
|
||||
|
||||
// Initialize boolean fields to prevent bind:checked={undefined} error
|
||||
if (lineItem.is_military_mcia === undefined) {
|
||||
lineItem.is_military_mcia = false;
|
||||
}
|
||||
if (descriptions.consider_a31 === undefined) {
|
||||
descriptions.consider_a31 = false;
|
||||
}
|
||||
|
||||
let paymentMethodDialogOpen = $state(false);
|
||||
let payment_method_description = $state('');
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { LineItem } from '$lib/api/dashboard/a76/items';
|
||||
import type { Item } from '$lib/api/dashboard/a76/items';
|
||||
|
||||
let { lineItem = $bindable() }: { lineItem: LineItem } = $props();
|
||||
let { lineItem = $bindable() }: { lineItem: Partial<Item> } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="border rounded-md p-3 space-y-3">
|
||||
|
||||
@@ -55,20 +55,19 @@
|
||||
);
|
||||
|
||||
// Derived state for easier binding and safety
|
||||
let line = $derived(editingItem.lines?.[0]);
|
||||
let line = $derived(editingItem);
|
||||
|
||||
// Initialize missing nested objects if they don't exist
|
||||
$effect(() => {
|
||||
if (open && editingItem) {
|
||||
if (!editingItem.lines) editingItem.lines = [{ line_number: 1 } as any];
|
||||
if (editingItem.lines[0] && !editingItem.lines[0].quantity)
|
||||
editingItem.lines[0].quantity = {} as any;
|
||||
if (editingItem.lines[0] && !editingItem.lines[0].financial)
|
||||
editingItem.lines[0].financial = {} as any;
|
||||
if (editingItem.lines[0] && !editingItem.lines[0].customs)
|
||||
editingItem.lines[0].customs = {} as any;
|
||||
if (editingItem.lines[0] && !editingItem.lines[0].description)
|
||||
editingItem.lines[0].description = {} as any;
|
||||
if (editingItem && !editingItem.quantity)
|
||||
editingItem.quantity = {} as any;
|
||||
if (editingItem && !editingItem.financial)
|
||||
editingItem.financial = {} as any;
|
||||
if (editingItem && !editingItem.customs)
|
||||
editingItem.customs = {} as any;
|
||||
if (editingItem && !editingItem.description)
|
||||
editingItem.description = {} as any;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -86,9 +85,9 @@
|
||||
{isEditMode
|
||||
? 'Modifica los campos del inventario y guarda los cambios.'
|
||||
: 'Completa la información del nuevo item de inventario.'}
|
||||
{#if editingItem.lines && editingItem.lines.length > 1}
|
||||
{#if editingItem}
|
||||
<Badge variant="secondary" class="ml-2">
|
||||
{editingItem.lines.length} items en esta partida
|
||||
{editingItem} items en esta partida
|
||||
</Badge>
|
||||
{/if}
|
||||
</Sheet.Description>
|
||||
|
||||
@@ -74,26 +74,23 @@
|
||||
// 4. Derived Values (Ordered correctly to avoid TDZ)
|
||||
const flattenedLines = $derived.by(() => {
|
||||
const sourceItems = items?.length ? items : formData?.items || [];
|
||||
return (sourceItems || []).flatMap((item: any, itemIndex: number) => {
|
||||
const lines = item?.lines || [];
|
||||
return lines.map((line: any, idx: number) => ({
|
||||
...line,
|
||||
id: line?.id || `${item?.id || itemIndex}-line-${line?.line_number ?? idx + 1}`,
|
||||
line_number: line?.line_number ?? idx + 1,
|
||||
reference_number: line?.reference_number ?? item?.reference_number,
|
||||
is_subitem: line?.is_subitem ?? false,
|
||||
class_code: line?.class_code ?? line?.class_id,
|
||||
class_description:
|
||||
line?.class_description ||
|
||||
line?.description?.description_spanish ||
|
||||
line?.description?.description_english ||
|
||||
'',
|
||||
unit_of_measure_code: line?.quantity?.unit_of_measure || line?.unit_of_measure,
|
||||
fa_data: line?.fa_data || {},
|
||||
warehouse: line?.warehouse || item?.warehouse,
|
||||
full_item: item
|
||||
}));
|
||||
});
|
||||
return (sourceItems || []).map((item: any, itemIndex: number) => ({
|
||||
...item,
|
||||
id: item?.id || `item-${itemIndex}`,
|
||||
line_number: item?.line_number ?? itemIndex + 1,
|
||||
reference_number: item?.reference_number,
|
||||
is_subitem: item?.is_subitem ?? false,
|
||||
class_code: item?.class_code ?? item?.class_id,
|
||||
class_description:
|
||||
item?.class_description ||
|
||||
item?.description?.description_spanish ||
|
||||
item?.description?.description_english ||
|
||||
'',
|
||||
unit_of_measure_code: item?.quantity?.unit_of_measure || item?.unit_of_measure,
|
||||
fa_data: item?.fa_data || {},
|
||||
warehouse: item?.warehouse,
|
||||
full_item: item
|
||||
}));
|
||||
});
|
||||
|
||||
const isAllSelected = $derived(
|
||||
@@ -248,80 +245,76 @@
|
||||
// Auto-asignar valores desde la factura con estructura completa
|
||||
editingItem = {
|
||||
invoice_id: invoice?.id,
|
||||
line_number: 1,
|
||||
// LineItem fields
|
||||
part_number: undefined,
|
||||
component_part_number: undefined,
|
||||
class_id: undefined,
|
||||
identifier: undefined,
|
||||
unit_of_measure: undefined,
|
||||
alternate_unit: undefined,
|
||||
permit_number: undefined,
|
||||
page_line: undefined,
|
||||
has_certificate: false,
|
||||
certificate_number: undefined,
|
||||
tax_payment: false,
|
||||
payment_method: undefined,
|
||||
igi_amount: undefined,
|
||||
is_military_mcia: false,
|
||||
wildcard_field: undefined,
|
||||
reference_number: '',
|
||||
order: invoice?.purchase_order || '',
|
||||
warehouse: '',
|
||||
location: '',
|
||||
lines: [
|
||||
{
|
||||
line_number: 1,
|
||||
// LineItem fields
|
||||
part_number: undefined,
|
||||
component_part_number: undefined,
|
||||
class_id: undefined,
|
||||
identifier: undefined,
|
||||
unit_of_measure: undefined,
|
||||
alternate_unit: undefined,
|
||||
permit_number: undefined,
|
||||
page_line: undefined,
|
||||
has_certificate: false,
|
||||
certificate_number: undefined,
|
||||
tax_payment: false,
|
||||
payment_method: undefined,
|
||||
igi_amount: undefined,
|
||||
is_military_mcia: false,
|
||||
wildcard_field: undefined,
|
||||
// Nested relations
|
||||
financial: {
|
||||
unit_cost_usd: undefined,
|
||||
unit_cost_mxn: undefined,
|
||||
unit_cost_capture: undefined,
|
||||
unit_cost_commercial_usd: undefined,
|
||||
value_usd: undefined,
|
||||
value_mxn: undefined,
|
||||
value_returned_usd: undefined,
|
||||
value_returned_mxn: undefined,
|
||||
customs_value_usd: undefined
|
||||
},
|
||||
quantity: {
|
||||
quantity: undefined,
|
||||
unit_of_measure: undefined,
|
||||
quantity_temp_export: undefined,
|
||||
quantity_returned: undefined,
|
||||
net_weight: undefined,
|
||||
gross_weight: undefined,
|
||||
package_id: undefined,
|
||||
package_quantity: undefined,
|
||||
package_description: undefined
|
||||
},
|
||||
customs: {
|
||||
fraction: undefined,
|
||||
fraction_type: undefined,
|
||||
american_fraction: undefined,
|
||||
origin_country: undefined,
|
||||
destination_country: undefined,
|
||||
advalorem: undefined,
|
||||
advalorem_american: undefined,
|
||||
sector: undefined
|
||||
},
|
||||
description: {
|
||||
description_spanish: undefined,
|
||||
description_english: undefined,
|
||||
extra_description: undefined,
|
||||
additional_info_spanish: undefined,
|
||||
brand: undefined,
|
||||
model: undefined,
|
||||
has_serial: false,
|
||||
eighth_rule_fraction: undefined,
|
||||
eighth_rule_line: undefined,
|
||||
consider_a31: false,
|
||||
machinery_location: undefined
|
||||
},
|
||||
reference: {
|
||||
serie_id: undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
location: '',
|
||||
// Nested relations
|
||||
financial: {
|
||||
unit_cost_usd: undefined,
|
||||
unit_cost_mxn: undefined,
|
||||
unit_cost_capture: undefined,
|
||||
unit_cost_commercial_usd: undefined,
|
||||
value_usd: undefined,
|
||||
value_mxn: undefined,
|
||||
value_returned_usd: undefined,
|
||||
value_returned_mxn: undefined,
|
||||
customs_value_usd: undefined
|
||||
},
|
||||
quantity: {
|
||||
quantity: undefined,
|
||||
unit_of_measure: undefined,
|
||||
quantity_temp_export: undefined,
|
||||
quantity_returned: undefined,
|
||||
net_weight: undefined,
|
||||
gross_weight: undefined,
|
||||
package_id: undefined,
|
||||
package_quantity: undefined,
|
||||
package_description: undefined
|
||||
},
|
||||
customs: {
|
||||
fraction: undefined,
|
||||
fraction_type: undefined,
|
||||
american_fraction: undefined,
|
||||
origin_country: undefined,
|
||||
destination_country: undefined,
|
||||
advalorem: undefined,
|
||||
advalorem_american: undefined,
|
||||
sector: undefined
|
||||
},
|
||||
description: {
|
||||
description_spanish: undefined,
|
||||
description_english: undefined,
|
||||
extra_description: undefined,
|
||||
additional_info_spanish: undefined,
|
||||
brand: undefined,
|
||||
model: undefined,
|
||||
has_serial: false,
|
||||
eighth_rule_fraction: undefined,
|
||||
eighth_rule_line: undefined,
|
||||
consider_a31: false,
|
||||
machinery_location: undefined
|
||||
},
|
||||
reference: {
|
||||
serie_id: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,14 +369,14 @@
|
||||
|
||||
function saveItemToPreset() {
|
||||
// Sanitizar datos para la plantilla
|
||||
const cleanedItem = JSON.parse(JSON.stringify(editingItem));
|
||||
let cleanedItem = JSON.parse(JSON.stringify(editingItem));
|
||||
|
||||
// Limpiar líneas para asegurar que son compatibles
|
||||
if (cleanedItem.lines) {
|
||||
cleanedItem.lines = cleanedItem.lines.map((line: any) => ({
|
||||
...cleanLineData(line),
|
||||
// Limpiar item para asegurar que es compatible
|
||||
if (cleanedItem) {
|
||||
cleanedItem = {
|
||||
...cleanLineData(cleanedItem),
|
||||
id: undefined // Las plantillas no deben tener IDs reales
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
if (editingBuilderIndex !== null) {
|
||||
@@ -409,35 +402,30 @@
|
||||
function cloneItemForPreset(item: Item) {
|
||||
const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any;
|
||||
return {
|
||||
...rest,
|
||||
...sanitizeLineForPreset(rest),
|
||||
id: undefined,
|
||||
invoice_id: undefined,
|
||||
lines: (item.lines || []).map(sanitizeLineForPreset)
|
||||
invoice_id: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function buildManualItem(draft: any, index: number) {
|
||||
return {
|
||||
return cleanLineData({
|
||||
id: undefined,
|
||||
temp_id: undefined,
|
||||
invoice_id: undefined,
|
||||
reference_number: draft.reference_number || undefined,
|
||||
lines: [
|
||||
cleanLineData({
|
||||
line_number: index + 1,
|
||||
description: {
|
||||
description_spanish: draft.description || 'Sin descripción'
|
||||
},
|
||||
quantity: {
|
||||
quantity: Number(draft.quantity) || 0
|
||||
},
|
||||
financial: {
|
||||
unit_cost_usd:
|
||||
draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
line_number: index + 1,
|
||||
description: {
|
||||
description_spanish: draft.description || 'Sin descripción'
|
||||
},
|
||||
quantity: {
|
||||
quantity: Number(draft.quantity) || 0
|
||||
},
|
||||
financial: {
|
||||
unit_cost_usd:
|
||||
draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddManualItem() {
|
||||
@@ -466,15 +454,11 @@
|
||||
}
|
||||
|
||||
// Inject into active sheet if open
|
||||
// No se pueden inyectar múltiples líneas en un item, ya que ahora un item ES una línea
|
||||
if (showItemSheet) {
|
||||
const presetLines = selectedPreset.items.flatMap((item: any) => item.lines || []);
|
||||
const cleanedNewLines = presetLines.map((line: any) => ({
|
||||
...sanitizeLineForPreset(line),
|
||||
id: undefined // Force new IDs
|
||||
}));
|
||||
|
||||
editingItem.lines = [...(editingItem.lines || []), ...cleanedNewLines];
|
||||
toast.success('Líneas inyectadas en la partida actual');
|
||||
toast.warning('No se puede inyectar plantilla en modo edición', {
|
||||
description: 'Las plantillas solo se pueden aplicar directamente a la factura'
|
||||
});
|
||||
showUsePresetDialog = false;
|
||||
return;
|
||||
}
|
||||
@@ -517,13 +501,13 @@
|
||||
|
||||
isSavingPreset = true;
|
||||
try {
|
||||
// We group everything as ONE Partida Template for injection
|
||||
const lines = builderItems.flatMap((item: Item, idx: number) => {
|
||||
return (item.lines || []).map((line: any) => ({
|
||||
...cleanLineData(line),
|
||||
line_number: line.line_number || idx + 1, // Ensure line_number is present
|
||||
// We group everything as items for the template
|
||||
const lines = builderItems.map((item: Item, idx: number) => {
|
||||
return {
|
||||
...cleanLineData(item),
|
||||
line_number: item.line_number || idx + 1, // Ensure line_number is present
|
||||
id: undefined // Ensure no IDs are saved in the preset
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const payloadItems = [
|
||||
@@ -569,22 +553,20 @@
|
||||
|
||||
// Enrich item with descriptive data for display
|
||||
async function enrichItemData(item: Partial<Item>) {
|
||||
if (!item.lines || item.lines.length === 0 || !activeCompanyId) return;
|
||||
|
||||
const line = item.lines[0];
|
||||
if (!item || !activeCompanyId) return;
|
||||
|
||||
// Load class data
|
||||
if (line.class_id) {
|
||||
if (item.class_id) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api-sveltekit/classes/${line.class_id}?company_id=${activeCompanyId}`,
|
||||
`/api-sveltekit/classes/${item.class_id}?company_id=${activeCompanyId}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (response.ok) {
|
||||
const classData = await response.json();
|
||||
(line as any).class_code = classData.class_code;
|
||||
(line as any).class_unit_of_measure = classData.unit_of_measure;
|
||||
(line as any).class_description = classData.description_es || classData.description_en;
|
||||
(item as any).class_code = classData.class_code;
|
||||
(item as any).class_unit_of_measure = classData.unit_of_measure;
|
||||
(item as any).class_description = classData.description_es || classData.description_en;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading class data:', error);
|
||||
@@ -592,17 +574,17 @@
|
||||
}
|
||||
|
||||
// Load part number data
|
||||
if (line.part_number) {
|
||||
if (item.part_number) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api-sveltekit/parts/${line.part_number}?company_id=${activeCompanyId}`,
|
||||
`/api-sveltekit/parts/${item.part_number}?company_id=${activeCompanyId}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (response.ok) {
|
||||
const partData = await response.json();
|
||||
(line as any).part_number = partData.part_number;
|
||||
(line as any).part_description_es = partData.description_spanish;
|
||||
(line as any).part_description_en = partData.description_english;
|
||||
(item as any).part_number = partData.part_number;
|
||||
(item as any).part_description_es = partData.description_spanish;
|
||||
(item as any).part_description_en = partData.description_english;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading part data:', error);
|
||||
@@ -610,16 +592,16 @@
|
||||
}
|
||||
|
||||
// Load unit of measure data
|
||||
if (line.unit_of_measure) {
|
||||
if (item.unit_of_measure) {
|
||||
try {
|
||||
const response = await fetch(`/api-sveltekit/units-of-measure/${line.unit_of_measure}`, {
|
||||
const response = await fetch(`/api-sveltekit/units-of-measure/${item.unit_of_measure}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const unitData = await response.json();
|
||||
(line as any).unit_code = unitData.code;
|
||||
(line as any).unit_description = unitData.description || unitData.description_en;
|
||||
(item as any).unit_code = unitData.code;
|
||||
(item as any).unit_description = unitData.description || unitData.description_en;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading unit data:', error);
|
||||
@@ -627,17 +609,17 @@
|
||||
}
|
||||
|
||||
// Load country data (if needed)
|
||||
if (line.customs?.origin_country) {
|
||||
if (item.customs?.origin_country) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api-sveltekit/countries?search=${line.customs.origin_country}`,
|
||||
`/api-sveltekit/countries?search=${item.customs.origin_country}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.items && data.items.length > 0) {
|
||||
const country = data.items[0];
|
||||
(line.customs as any).origin_country_name =
|
||||
(item.customs as any).origin_country_name =
|
||||
country.description || country.description_en;
|
||||
}
|
||||
}
|
||||
@@ -647,17 +629,17 @@
|
||||
}
|
||||
|
||||
// Load fraction data (if needed)
|
||||
if (line.customs?.fraction) {
|
||||
if (item.customs?.fraction) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api-sveltekit/tariff-fractions?search=${line.customs.fraction}`,
|
||||
`/api-sveltekit/tariff-fractions?search=${item.customs.fraction}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.items && data.items.length > 0) {
|
||||
const fraction = data.items[0];
|
||||
(line.customs as any).fraction_description = fraction.description;
|
||||
(item.customs as any).fraction_description = fraction.description;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -666,8 +648,8 @@
|
||||
}
|
||||
|
||||
// Load package data (if needed)
|
||||
const packageId = line.quantity?.package_id;
|
||||
if (packageId && line.quantity) {
|
||||
const packageId = item.quantity?.package_id;
|
||||
if (packageId && item.quantity) {
|
||||
try {
|
||||
const response = await fetch('/api-sveltekit/packages', {
|
||||
method: 'GET',
|
||||
@@ -679,9 +661,9 @@
|
||||
if (Array.isArray(packages)) {
|
||||
const pkg = packages.find((p: any) => p.id === packageId);
|
||||
if (pkg) {
|
||||
(line.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key;
|
||||
(line.quantity as any).package_key = pkg.key;
|
||||
(line.quantity as any).package_weight_unit = pkg.weight_unit || 0;
|
||||
(item.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key;
|
||||
(item.quantity as any).package_key = pkg.key;
|
||||
(item.quantity as any).package_weight_unit = pkg.weight_unit || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,7 +673,7 @@
|
||||
}
|
||||
|
||||
// Load payment method description (if needed)
|
||||
if (line.payment_method) {
|
||||
if (item.payment_method) {
|
||||
try {
|
||||
const response = await fetch('/api-sveltekit/payment-methods', {
|
||||
method: 'GET',
|
||||
@@ -701,9 +683,9 @@
|
||||
const data = await response.json();
|
||||
const methods = data.items || data.data || data;
|
||||
if (Array.isArray(methods)) {
|
||||
const method = methods.find((m: any) => m.key === line.payment_method);
|
||||
const method = methods.find((m: any) => m.key === item.payment_method);
|
||||
if (method) {
|
||||
(line as any).payment_method_description = method.description;
|
||||
(item as any).payment_method_description = method.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,58 +697,56 @@
|
||||
|
||||
// Normalize numeric values from strings to numbers
|
||||
function normalizeItemData(item: Partial<Item>): Partial<Item> {
|
||||
if (item.lines && item.lines.length > 0) {
|
||||
item.lines = item.lines.map((line) => {
|
||||
const normalizedLine = { ...line };
|
||||
if (item) {
|
||||
const normalizedItem = { ...item };
|
||||
|
||||
// Normalize financials
|
||||
if (normalizedLine.financial) {
|
||||
normalizedLine.financial = {
|
||||
...normalizedLine.financial,
|
||||
unit_cost_usd:
|
||||
normalizedLine.financial.unit_cost_usd != null
|
||||
? Number(normalizedLine.financial.unit_cost_usd)
|
||||
: undefined,
|
||||
unit_cost_mxn:
|
||||
normalizedLine.financial.unit_cost_mxn != null
|
||||
? Number(normalizedLine.financial.unit_cost_mxn)
|
||||
: undefined,
|
||||
value_usd:
|
||||
normalizedLine.financial.value_usd != null
|
||||
? Number(normalizedLine.financial.value_usd)
|
||||
: undefined,
|
||||
value_mxn:
|
||||
normalizedLine.financial.value_mxn != null
|
||||
? Number(normalizedLine.financial.value_mxn)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
// Normalize financials
|
||||
if (normalizedItem.financial) {
|
||||
normalizedItem.financial = {
|
||||
...normalizedItem.financial,
|
||||
unit_cost_usd:
|
||||
normalizedItem.financial.unit_cost_usd != null
|
||||
? Number(normalizedItem.financial.unit_cost_usd)
|
||||
: undefined,
|
||||
unit_cost_mxn:
|
||||
normalizedItem.financial.unit_cost_mxn != null
|
||||
? Number(normalizedItem.financial.unit_cost_mxn)
|
||||
: undefined,
|
||||
value_usd:
|
||||
normalizedItem.financial.value_usd != null
|
||||
? Number(normalizedItem.financial.value_usd)
|
||||
: undefined,
|
||||
value_mxn:
|
||||
normalizedItem.financial.value_mxn != null
|
||||
? Number(normalizedItem.financial.value_mxn)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize quantities
|
||||
if (normalizedLine.quantity) {
|
||||
normalizedLine.quantity = {
|
||||
...normalizedLine.quantity,
|
||||
quantity:
|
||||
normalizedLine.quantity.quantity != null
|
||||
? Number(normalizedLine.quantity.quantity)
|
||||
: undefined,
|
||||
net_weight:
|
||||
normalizedLine.quantity.net_weight != null
|
||||
? Number(normalizedLine.quantity.net_weight)
|
||||
: undefined,
|
||||
gross_weight:
|
||||
normalizedLine.quantity.gross_weight != null
|
||||
? Number(normalizedLine.quantity.gross_weight)
|
||||
: undefined,
|
||||
package_quantity:
|
||||
normalizedLine.quantity.package_quantity != null
|
||||
? Number(normalizedLine.quantity.package_quantity)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
// Normalize quantities
|
||||
if (normalizedItem.quantity) {
|
||||
normalizedItem.quantity = {
|
||||
...normalizedItem.quantity,
|
||||
quantity:
|
||||
normalizedItem.quantity.quantity != null
|
||||
? Number(normalizedItem.quantity.quantity)
|
||||
: undefined,
|
||||
net_weight:
|
||||
normalizedItem.quantity.net_weight != null
|
||||
? Number(normalizedItem.quantity.net_weight)
|
||||
: undefined,
|
||||
gross_weight:
|
||||
normalizedItem.quantity.gross_weight != null
|
||||
? Number(normalizedItem.quantity.gross_weight)
|
||||
: undefined,
|
||||
package_quantity:
|
||||
normalizedItem.quantity.package_quantity != null
|
||||
? Number(normalizedItem.quantity.package_quantity)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
return normalizedLine;
|
||||
});
|
||||
return normalizedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
@@ -782,16 +762,12 @@
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Clean lines data before sending
|
||||
const cleanedLines = (editingItem.lines || []).map(cleanLineData);
|
||||
// Clean item data before sending
|
||||
const cleanedItem = cleanLineData(editingItem);
|
||||
|
||||
const response = await itemsApi.create(activeCompanyId, {
|
||||
invoice_id: invoice.id,
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
...cleanedItem,
|
||||
invoice_id: invoice.id
|
||||
});
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
@@ -851,16 +827,10 @@
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Clean lines data before sending
|
||||
const cleanedLines = (editingItem.lines || []).map(cleanLineData);
|
||||
// Clean item data before sending
|
||||
const cleanedItem = cleanLineData(editingItem);
|
||||
|
||||
const response = await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
});
|
||||
const response = await itemsApi.update(selectedItem.id, activeCompanyId, cleanedItem);
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
if ('error' in response) {
|
||||
@@ -916,52 +886,51 @@
|
||||
|
||||
function saveItem() {
|
||||
// Validar campos obligatorios antes de guardar
|
||||
const line = editingItem.lines?.[0];
|
||||
const missingFields: string[] = [];
|
||||
|
||||
if (!line) {
|
||||
if (!editingItem) {
|
||||
toast.warning('Error de datos', {
|
||||
description: 'No se encontró información de la línea del item'
|
||||
description: 'No se encontró información del item'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Clase
|
||||
if (!line.class_id) {
|
||||
if (!editingItem.class_id) {
|
||||
missingFields.push('Clase');
|
||||
}
|
||||
|
||||
// 2. Cantidad
|
||||
if (!line.quantity?.quantity || line.quantity.quantity <= 0) {
|
||||
if (!editingItem.quantity?.quantity || editingItem.quantity.quantity <= 0) {
|
||||
missingFields.push('Cantidad');
|
||||
}
|
||||
|
||||
// 3. Unidad de Medida
|
||||
if (!line.unit_of_measure) {
|
||||
if (!editingItem.unit_of_measure) {
|
||||
missingFields.push('U.M. (Unidad de Medida)');
|
||||
}
|
||||
|
||||
// 4. Costo Unitario (al menos uno debe estar presente)
|
||||
const hasCost =
|
||||
line.financial?.unit_cost_usd ||
|
||||
line.financial?.unit_cost_mxn ||
|
||||
line.financial?.unit_cost_capture;
|
||||
editingItem.financial?.unit_cost_usd ||
|
||||
editingItem.financial?.unit_cost_mxn ||
|
||||
editingItem.financial?.unit_cost_capture;
|
||||
if (!hasCost) {
|
||||
missingFields.push('Costo Unitario (USD, MXN o Captura)');
|
||||
}
|
||||
|
||||
// 5. País de Origen
|
||||
if (!line.customs?.origin_country) {
|
||||
if (!editingItem.customs?.origin_country) {
|
||||
missingFields.push('País de Origen');
|
||||
}
|
||||
|
||||
// 6. Tipo de Tarifa
|
||||
if (!line.customs?.fraction_type) {
|
||||
if (!editingItem.customs?.fraction_type) {
|
||||
missingFields.push('Tipo de Tarifa');
|
||||
}
|
||||
|
||||
// 7. Descripción en Español
|
||||
if (!line.description?.description_spanish?.trim()) {
|
||||
if (!editingItem.description?.description_spanish?.trim()) {
|
||||
missingFields.push('Descripción en Español');
|
||||
}
|
||||
|
||||
@@ -1453,7 +1422,7 @@
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col py-0.5">
|
||||
<span class="font-medium text-sm text-foreground/90">
|
||||
{item.lines?.[0]?.description?.description_spanish ||
|
||||
{item?.description?.description_spanish ||
|
||||
'Sin descripción'}
|
||||
</span>
|
||||
{#if item.reference_number}
|
||||
@@ -1464,10 +1433,10 @@
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center font-medium tabular-nums">
|
||||
{item.lines?.[0]?.quantity?.quantity || 0}
|
||||
{item?.quantity?.quantity || 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums text-muted-foreground">
|
||||
${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString(
|
||||
${(item?.financial?.unit_cost_usd || 0).toLocaleString(
|
||||
undefined,
|
||||
{ minimumFractionDigits: 2 }
|
||||
)}
|
||||
@@ -1584,7 +1553,7 @@
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">
|
||||
{item.lines?.[0]?.description?.description_spanish || 'Sin descripción'}
|
||||
{item?.[0]?.description?.description_spanish || 'Sin descripción'}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>Ref: {item.reference_number || '-'}</span
|
||||
@@ -1592,7 +1561,7 @@
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center text-sm">
|
||||
{item.lines?.[0]?.quantity?.quantity || 0}
|
||||
{item?.[0]?.quantity?.quantity || 0}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right pr-2">
|
||||
<div class="flex justify-end gap-1">
|
||||
|
||||
@@ -1,130 +1,132 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Progress } from "$lib/components/ui/progress";
|
||||
import { invoicesReportsApi } from "$lib/api/dashboard/a76/reports/reports-invoices";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Loader2, CheckCircle2, XCircle, FileDown } from "lucide-svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Loader2, CheckCircle2, XCircle, FileDown } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
export let open = false;
|
||||
export let taskId: string | null = null;
|
||||
export let onClose: () => void;
|
||||
export let onComplete: (result: any) => void;
|
||||
export let open = false;
|
||||
export let taskId: string | null = null;
|
||||
export let onClose: () => void;
|
||||
export let onComplete: (result: any) => void;
|
||||
export let title: string = 'Generando PDF';
|
||||
|
||||
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
|
||||
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
|
||||
|
||||
let progress = 0;
|
||||
let statusMessage = "Iniciando...";
|
||||
let pollingInterval: any = null;
|
||||
let isComplete = false;
|
||||
let hasError = false;
|
||||
let progress = 0;
|
||||
let statusMessage = 'Iniciando...';
|
||||
let pollingInterval: any = null;
|
||||
let isComplete = false;
|
||||
let hasError = false;
|
||||
|
||||
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
|
||||
$: if (open && taskId) {
|
||||
progress = 0;
|
||||
statusMessage = "Iniciando...";
|
||||
isComplete = false;
|
||||
hasError = false;
|
||||
startPolling();
|
||||
} else if (!open) {
|
||||
stopPolling();
|
||||
}
|
||||
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
|
||||
$: if (open && taskId) {
|
||||
progress = 0;
|
||||
statusMessage = 'Iniciando...';
|
||||
isComplete = false;
|
||||
hasError = false;
|
||||
startPolling();
|
||||
} else if (!open) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startPolling() {
|
||||
stopPolling(); // Asegurar limpieza previa
|
||||
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (!taskId) return;
|
||||
async function startPolling() {
|
||||
stopPolling(); // Asegurar limpieza previa
|
||||
|
||||
try {
|
||||
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
|
||||
const response = await apiCall(taskId);
|
||||
|
||||
if (response.state === 'PROCESSING' && response.info) {
|
||||
progress = response.info.current || 0;
|
||||
statusMessage = response.info.status || "Procesando...";
|
||||
}
|
||||
else if (response.state === 'SUCCESS') {
|
||||
progress = 100;
|
||||
statusMessage = "¡Completado!";
|
||||
isComplete = true;
|
||||
stopPolling();
|
||||
// Pequeña pausa para ver el 100%
|
||||
setTimeout(() => {
|
||||
onComplete(response.result);
|
||||
}, 500);
|
||||
} else if (response.state === 'FAILURE') {
|
||||
hasError = true;
|
||||
// Intenta mostrar el mensaje de error real si viene en 'result'
|
||||
const errMsg = response.result ? String(response.result) : 'Error desconocido';
|
||||
statusMessage = `Error: ${errMsg}`;
|
||||
stopPolling();
|
||||
toast.error(`Falló la generación: ${errMsg}`);
|
||||
console.error('Task failed with result:', response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
// No detenemos el polling inmediatamente por un error de red transitorio,
|
||||
// pero podríamos contar intentos fallidos si fuera necesario.
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
pollingInterval = setInterval(async () => {
|
||||
if (!taskId) return;
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
|
||||
const response = await apiCall(taskId);
|
||||
|
||||
if (response.state === 'PROCESSING' && response.info) {
|
||||
progress = response.info.current || 0;
|
||||
statusMessage = response.info.status || 'Procesando...';
|
||||
} else if (response.state === 'SUCCESS') {
|
||||
progress = 100;
|
||||
statusMessage = '¡Completado!';
|
||||
isComplete = true;
|
||||
stopPolling();
|
||||
// Pequeña pausa para ver el 100%
|
||||
setTimeout(() => {
|
||||
onComplete(response.result);
|
||||
}, 500);
|
||||
} else if (response.state === 'FAILURE') {
|
||||
hasError = true;
|
||||
// Intenta mostrar el mensaje de error real si viene en 'result'
|
||||
const errMsg = response.result ? String(response.result) : 'Error desconocido';
|
||||
statusMessage = `Error: ${errMsg}`;
|
||||
stopPolling();
|
||||
toast.error(`Falló la generación: ${errMsg}`);
|
||||
console.error('Task failed with result:', response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling task status:', error);
|
||||
// No detenemos el polling inmediatamente por un error de red transitorio,
|
||||
// pero podríamos contar intentos fallidos si fuera necesario.
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
stopPolling();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Generando PDF</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Por favor espere mientras se genera su documento.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>Por favor espere mientras se genera su documento.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-6 flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between text-sm mb-1">
|
||||
<span class="text-muted-foreground">{statusMessage}</span>
|
||||
<span class="font-medium">{progress}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={progress} class="w-full h-2" />
|
||||
<div class="flex flex-col gap-6 py-6">
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{statusMessage}</span>
|
||||
<span class="font-medium">{progress}%</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center h-16">
|
||||
{#if isComplete}
|
||||
<div class="flex flex-col items-center text-green-600 animate-in fade-in zoom-in duration-300">
|
||||
<CheckCircle2 size={48} />
|
||||
<span class="text-sm font-medium mt-2">Listo para descargar</span>
|
||||
</div>
|
||||
{:else if hasError}
|
||||
<div class="flex flex-col items-center text-destructive animate-in fade-in zoom-in duration-300">
|
||||
<XCircle size={48} />
|
||||
<span class="text-sm font-medium mt-2">Ocurrió un error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center text-primary animate-pulse">
|
||||
<FileDown size={48} class="opacity-50" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} class="h-2 w-full" />
|
||||
|
||||
<Dialog.Footer>
|
||||
{#if hasError}
|
||||
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
<div class="flex h-16 items-center justify-center">
|
||||
{#if isComplete}
|
||||
<div
|
||||
class="animate-in fade-in zoom-in flex flex-col items-center text-green-600 duration-300"
|
||||
>
|
||||
<CheckCircle2 size={48} />
|
||||
<span class="mt-2 text-sm font-medium">Listo para descargar</span>
|
||||
</div>
|
||||
{:else if hasError}
|
||||
<div
|
||||
class="animate-in fade-in zoom-in flex flex-col items-center text-destructive duration-300"
|
||||
>
|
||||
<XCircle size={48} />
|
||||
<span class="mt-2 text-sm font-medium">Ocurrió un error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex animate-pulse flex-col items-center text-primary">
|
||||
<FileDown size={48} class="opacity-50" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
{#if hasError}
|
||||
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Send, FileText, Settings, Database, Folder, X } from 'lucide-svelte';
|
||||
import ManifestSelectorModal from './edit/ManifestSelectorModal.svelte';
|
||||
import InvoiceSelectorModal from './edit/InvoiceSelectorModal.svelte';
|
||||
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
|
||||
import PortSelectorDialog from '$lib/components/dashboard/export/manifest/modals/port-selector-dialog.svelte';
|
||||
import { reportsTransmissionApi } from '$lib/api/dashboard/a76/reports/reports-transmission';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table';
|
||||
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { manifestApi as manifestsApi } from '$lib/api/dashboard/a76/manifests';
|
||||
|
||||
let { open = $bindable(false), invoice = null } = $props<{
|
||||
open: boolean;
|
||||
invoice?: any;
|
||||
}>();
|
||||
|
||||
// --- STATE ---
|
||||
let interfaceType = $state('MAINX30');
|
||||
let movementType = $state('Exportacion');
|
||||
let regimen = $state('Temporal');
|
||||
let activeTab = $state('movimiento');
|
||||
|
||||
let manifests = $state<string[]>([]);
|
||||
|
||||
// Selector Modal State
|
||||
let isManifestSelectorOpen = $state(false);
|
||||
let currentManifestIndex = $state(0);
|
||||
|
||||
// Invoice Manual State (12 slots)
|
||||
let manualInvoices = $state<string[]>([]);
|
||||
let isInvoiceSelectorOpen = $state(false);
|
||||
let currentInvoiceIndex = $state(0);
|
||||
|
||||
// Progress Dialog State
|
||||
let isProgressOpen = $state(false);
|
||||
let taskId = $state<string | null>(null);
|
||||
let downloadUrl = $state<string | null>(null);
|
||||
let fileName = $state<string | null>(null);
|
||||
let progressTitle = $state('Generando Archivo de Transmisión');
|
||||
let isTemporalTask = $state(false);
|
||||
let isDefinitiveTask = $state(false);
|
||||
|
||||
// Ports State
|
||||
let entryPort = $state('');
|
||||
let exitPort = $state('');
|
||||
let openEntryPortDialog = $state(false);
|
||||
let openExitPortDialog = $state(false);
|
||||
|
||||
// Invoices State
|
||||
let invoices = $state<Invoice[]>([]);
|
||||
let loading = $state(false);
|
||||
let items = $state<any[]>([]); // manifest items
|
||||
let selectedItems = $state<Set<string>>(new Set());
|
||||
let selectedInvoices = $state<Set<number>>(new Set());
|
||||
// Assuming selectedItems and requestEmail are defined elsewhere or will be added
|
||||
// requestEmail removed as placeholder
|
||||
|
||||
// Checkboxes State (matching backend schemas)
|
||||
let checks = $state({
|
||||
nomenclatura_factura: false,
|
||||
consolidar_rbs: false,
|
||||
emanifest_fast_blanco: false,
|
||||
no_enviar_emanifest: false,
|
||||
consolidar_partidas: false,
|
||||
main_x40_emanifest: false,
|
||||
main_x30_fedex: false,
|
||||
iv11: false,
|
||||
iv42: false
|
||||
});
|
||||
|
||||
// --- OPTIONS ---
|
||||
const interfaceOptions = [
|
||||
{ value: 'MAINX30', label: 'MAINX30' },
|
||||
{ value: 'MAINX40', label: 'MAINX40' },
|
||||
{ value: 'EDI-EDA RB SYSTEMS', label: 'EDI-EDA RB SYSTEMS' },
|
||||
{ value: 'EDI-EDA EXPEDITORS', label: 'EDI-EDA EXPEDITORS' },
|
||||
{ value: 'EDI KNEXPRESS', label: 'EDI KNEXPRESS' },
|
||||
{ value: 'EDI-EDA V2', label: 'EDI-EDA V2' }
|
||||
];
|
||||
|
||||
const movementOptions = [
|
||||
{ value: 'Exportacion', label: 'Exportación' },
|
||||
{ value: 'Importacion', label: 'Importación' }
|
||||
];
|
||||
|
||||
// --- ACTIONS ---
|
||||
function handleClose() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function handleAction() {
|
||||
if (movementType === 'Importacion') {
|
||||
const validInvoices = manualInvoices.filter((i) => i && i.trim() !== '');
|
||||
if (validInvoices.length === 0) {
|
||||
toast.error('Debe seleccionar al menos una factura');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entryPort || !exitPort) {
|
||||
toast.error('Debe seleccionar tanto el puerto de entrada como el de salida');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
regimen,
|
||||
facturas: validInvoices,
|
||||
entry_port: entryPort,
|
||||
exit_port: exitPort,
|
||||
...checks
|
||||
};
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
res = await reportsTransmissionApi.triggerDefinitiveGeneration(payload);
|
||||
isDefinitiveTask = true;
|
||||
isTemporalTask = false;
|
||||
} else {
|
||||
res = await reportsTransmissionApi.triggerTemporalGeneration(payload);
|
||||
isTemporalTask = true;
|
||||
isDefinitiveTask = false;
|
||||
}
|
||||
|
||||
if (res.task_id) {
|
||||
taskId = res.task_id;
|
||||
isProgressOpen = true;
|
||||
downloadUrl = null;
|
||||
fileName = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering transmission file generation:', error);
|
||||
toast.error('Error al iniciar la generación del archivo');
|
||||
}
|
||||
} else {
|
||||
// Exportacion Logic (Manifests)
|
||||
const validManifests = manifests.filter((m) => m && m.trim() !== '');
|
||||
|
||||
if (validManifests.length === 0) {
|
||||
toast.error('Debe seleccionar al menos un manifiesto');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
manifiestos: validManifests,
|
||||
...checks
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await reportsTransmissionApi.triggerGeneration(payload);
|
||||
isTemporalTask = false;
|
||||
|
||||
if (res.task_id) {
|
||||
taskId = res.task_id;
|
||||
isProgressOpen = true;
|
||||
downloadUrl = null;
|
||||
fileName = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering transmission file generation:', error);
|
||||
toast.error('Error al iniciar la generación del archivo');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManifests() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
loading = true;
|
||||
try {
|
||||
// If Importacion, load Invoices instead
|
||||
if (movementType === 'Importacion') {
|
||||
let filters: any = {
|
||||
operation_type: 'imp'
|
||||
};
|
||||
|
||||
// Filter by Regimen
|
||||
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
|
||||
filters.invoice_type = 'TEM';
|
||||
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
filters.invoice_type = 'DEF';
|
||||
}
|
||||
|
||||
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 100, filters);
|
||||
invoices = res?.data?.items || [];
|
||||
} else {
|
||||
// Existing Manifest Logic
|
||||
const res = await manifestsApi.list(companyStore.activeCompany.id, {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'open'
|
||||
});
|
||||
items = res?.data?.items || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
toast.error('Error al cargar datos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && companyStore.activeCompany?.id) {
|
||||
loadManifests();
|
||||
// Pre-fill first slot if we have a specific invoice and it's empty
|
||||
if (invoice?.invoice_number && !manualInvoices.includes(invoice.invoice_number)) {
|
||||
manualInvoices.push(invoice.invoice_number);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-load when movement type changes
|
||||
// Re-load when movement type or regimen changes
|
||||
$effect(() => {
|
||||
if (open && movementType) {
|
||||
// Trigger re-load when movementType or regimen changes
|
||||
// We access regimen here so it becomes a dependency
|
||||
const currentRegimen = regimen;
|
||||
loadManifests();
|
||||
// Clear selections
|
||||
selectedItems.clear();
|
||||
selectedInvoices.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function openManifestSelector(index: number) {
|
||||
currentManifestIndex = index;
|
||||
isManifestSelectorOpen = true;
|
||||
}
|
||||
|
||||
function handleManifestSelect(manifest: any) {
|
||||
if (currentManifestIndex === -1) {
|
||||
manifests.push(manifest.manifest_number);
|
||||
} else {
|
||||
manifests[currentManifestIndex] = manifest.manifest_number;
|
||||
}
|
||||
isManifestSelectorOpen = false;
|
||||
}
|
||||
|
||||
function openInvoiceSelector(index: number) {
|
||||
currentInvoiceIndex = index;
|
||||
isInvoiceSelectorOpen = true;
|
||||
}
|
||||
|
||||
function handleInvoiceSelect(invoice: any) {
|
||||
if (currentInvoiceIndex === -1) {
|
||||
manualInvoices.push(invoice.invoice_number);
|
||||
} else {
|
||||
manualInvoices[currentInvoiceIndex] = invoice.invoice_number;
|
||||
}
|
||||
isInvoiceSelectorOpen = false;
|
||||
}
|
||||
|
||||
// This function is expected by PdfProgressDialog to check status
|
||||
async function checkTaskStatus(id: string) {
|
||||
if (isDefinitiveTask) {
|
||||
return await reportsTransmissionApi.getDefinitiveTaskStatus(id);
|
||||
}
|
||||
if (isTemporalTask) {
|
||||
return await reportsTransmissionApi.getTemporalTaskStatus(id);
|
||||
}
|
||||
return await reportsTransmissionApi.getTaskStatus(id);
|
||||
}
|
||||
|
||||
function handleDownloadComplete(result: any) {
|
||||
if (result && result.content && result.file_name) {
|
||||
try {
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(result.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: result.media_type || 'application/octet-stream'
|
||||
});
|
||||
|
||||
// Create link and download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
// @ts-ignore
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success('Archivo descargado correctamente');
|
||||
} catch (e) {
|
||||
console.error('Error downloading file', e);
|
||||
toast.error('Error al descargar el archivo');
|
||||
}
|
||||
}
|
||||
|
||||
isProgressOpen = false;
|
||||
taskId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[1200px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Interfase Broker Americano</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Configura la transferencia electrónica para la factura {invoice?.invoice_number || ''}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<!-- TOP HEADER INPUTS -->
|
||||
<div class="grid grid-cols-1 gap-4 rounded-lg border p-3 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label>INTERFASE</Label>
|
||||
<Select.Root type="single" bind:value={interfaceType}>
|
||||
<Select.Trigger>{interfaceType}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each interfaceOptions as opt}
|
||||
<Select.Item value={opt.value} label={opt.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>Movimiento</Label>
|
||||
<Select.Root type="single" bind:value={movementType}>
|
||||
<Select.Trigger>{movementType}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each movementOptions as opt}
|
||||
<Select.Item value={opt.value} label={opt.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if movementType === 'Importacion'}
|
||||
<div class="grid gap-2">
|
||||
<Label>Regimen</Label>
|
||||
<Select.Root type="single" bind:value={regimen}>
|
||||
<Select.Trigger>{regimen}</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="Temporal" label="Temporal" />
|
||||
<Select.Item value="Definitiva" label="Definitiva" />
|
||||
<Select.Item value="TEMPORAL SCAF" label="TEMPORAL SCAF" />
|
||||
<Select.Item value="DEFINITIVO SCAF" label="DEFINITIVO SCAF" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- MAIN TABS -->
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<!-- TAB: MOVIMIENTO -->
|
||||
<Tabs.Content value="movimiento" class="space-y-4 pt-2">
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<!-- LEFT: Manifests Table -->
|
||||
<div class="flex-1 space-y-3">
|
||||
{#if movementType === 'Exportacion'}
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<Label class="text-base font-medium">MANIFIESTOS (ENTRYS)</Label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each manifests as m, i}
|
||||
<div
|
||||
class="group relative flex flex-col items-center justify-center rounded-lg border-2 border-solid border-blue-500 bg-blue-50/30 p-4 transition-all"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openManifestSelector(i)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && openManifestSelector(i)}
|
||||
>
|
||||
<span class="absolute top-1 left-2 text-[10px] font-bold text-blue-400"
|
||||
>{i + 1}</span
|
||||
>
|
||||
<div
|
||||
class="w-full cursor-pointer truncate text-center text-sm font-semibold text-blue-700"
|
||||
>
|
||||
{m}
|
||||
</div>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 z-20 rounded-full bg-destructive p-1 text-white shadow-md hover:scale-110 active:scale-95"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
manifests.splice(i, 1);
|
||||
}}
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add Slot -->
|
||||
<div
|
||||
class="flex h-[72px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted p-4 transition-all hover:border-blue-400 hover:bg-blue-50/50"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openManifestSelector(-1)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && openManifestSelector(-1)}
|
||||
>
|
||||
<Folder class="mb-1 h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-xs font-medium text-muted-foreground">Agregar...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-md border p-3">
|
||||
{#if movementType === 'Importacion'}
|
||||
<div class="mb-2 text-base font-medium">FACTURAS ({regimen?.toUpperCase()})</div>
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each manualInvoices as m, i}
|
||||
<div
|
||||
class="group relative flex flex-col items-center justify-center rounded-lg border-2 border-solid border-emerald-500 bg-emerald-50/30 p-4 transition-all"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openInvoiceSelector(i)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && openInvoiceSelector(i)}
|
||||
>
|
||||
<span class="absolute top-1 left-2 text-[10px] font-bold text-emerald-400"
|
||||
>{i + 1}</span
|
||||
>
|
||||
<div
|
||||
class="w-full cursor-pointer truncate text-center text-sm font-semibold text-emerald-700"
|
||||
>
|
||||
{m}
|
||||
</div>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 z-20 rounded-full bg-destructive p-1 text-white shadow-md hover:scale-110 active:scale-95"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
manualInvoices.splice(i, 1);
|
||||
}}
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add Slot -->
|
||||
<div
|
||||
class="flex h-[72px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted p-4 transition-all hover:border-emerald-400 hover:bg-emerald-50/50"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openInvoiceSelector(-1)}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && openInvoiceSelector(-1)}
|
||||
>
|
||||
<Folder class="mb-1 h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-xs font-medium text-muted-foreground">Agregar...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ports Selection (Only for Importacion) -->
|
||||
{#if movementType === 'Importacion'}
|
||||
<div class="mt-2 grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Puerto Entrada</Label>
|
||||
<div class="relative">
|
||||
<Input bind:value={entryPort} placeholder="Seleccionar puerto..." readonly />
|
||||
<div class="absolute top-0 right-0 flex h-full">
|
||||
{#if entryPort}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (entryPort = '')}
|
||||
title="Limpiar"
|
||||
>
|
||||
<X class="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (openEntryPortDialog = true)}
|
||||
title="Seleccionar"
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Puerto Salida</Label>
|
||||
<div class="relative">
|
||||
<Input bind:value={exitPort} placeholder="Seleccionar puerto..." readonly />
|
||||
<div class="absolute top-0 right-0 flex h-full">
|
||||
{#if exitPort}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (exitPort = '')}
|
||||
title="Limpiar"
|
||||
>
|
||||
<X class="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (openExitPortDialog = true)}
|
||||
title="Seleccionar"
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM: Checklists -->
|
||||
<div class="border-t pt-2">
|
||||
<Label class="mb-2 block text-base font-medium">Opciones de Procesamiento</Label>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk0" bind:checked={checks.nomenclatura_factura} />
|
||||
<Label for="chk0" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
Consolidar por Factura (Nomenclatura)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk1" bind:checked={checks.consolidar_rbs} />
|
||||
<Label for="chk1" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
Consolidar por fracción solamente Archivo EDI de RB System
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk2" bind:checked={checks.emanifest_fast_blanco} />
|
||||
<Label for="chk2" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
E-Manifest y Fast en Blanco
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk3" bind:checked={checks.no_enviar_emanifest} />
|
||||
<Label for="chk3" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
No enviar E-Manifest
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk4" bind:checked={checks.consolidar_partidas} />
|
||||
<Label for="chk4" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
Consolidar Partidas (XML OPTIMA Y RBS2)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk5" bind:checked={checks.main_x40_emanifest} />
|
||||
<Label for="chk5" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
Main X40 E-Manifest
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk6" bind:checked={checks.main_x30_fedex} />
|
||||
<Label for="chk6" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
Main X30 (FEDEX)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk7" bind:checked={checks.iv11} />
|
||||
<Label for="chk7" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
IV11
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-2">
|
||||
<Checkbox id="chk8" bind:checked={checks.iv42} />
|
||||
<Label for="chk8" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
|
||||
IV42
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: RESPALDOS -->
|
||||
<Tabs.Content
|
||||
value="respaldos"
|
||||
class="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/10"
|
||||
>
|
||||
<div class="text-center text-muted-foreground">
|
||||
<Database class="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>Configuración de respaldos (Pendiente)</p>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: CONFIGURACION -->
|
||||
<Tabs.Content
|
||||
value="configuracion"
|
||||
class="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/10"
|
||||
>
|
||||
<div class="text-center text-muted-foreground">
|
||||
<Settings class="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>Configuración general (Pendiente)</p>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TABS LIST MOVED TO BOTTOM -->
|
||||
<Tabs.List class="mt-4 grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="movimiento">
|
||||
<FileText class="mr-2 h-4 w-4" /> Movimiento
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="respaldos">
|
||||
<Database class="mr-2 h-4 w-4" /> Respaldos
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="configuracion">
|
||||
<Settings class="mr-2 h-4 w-4" /> Configuración
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleClose}>Cerrar</Button>
|
||||
<Button onclick={handleAction} class="bg-blue-600 text-white shadow hover:bg-blue-700">
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Generar Archivo
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
<ManifestSelectorModal bind:open={isManifestSelectorOpen} onSelect={handleManifestSelect} />
|
||||
|
||||
<InvoiceSelectorModal
|
||||
bind:open={isInvoiceSelectorOpen}
|
||||
{regimen}
|
||||
onSelect={handleInvoiceSelect}
|
||||
/>
|
||||
|
||||
{#if taskId}
|
||||
<PdfProgressDialog
|
||||
bind:open={isProgressOpen}
|
||||
{taskId}
|
||||
title={progressTitle}
|
||||
getStatus={checkTaskStatus}
|
||||
onComplete={handleDownloadComplete}
|
||||
onClose={() => (isProgressOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PortSelectorDialog
|
||||
bind:open={openEntryPortDialog}
|
||||
onSelect={(item) => (entryPort = item.port_code)}
|
||||
/>
|
||||
|
||||
<PortSelectorDialog
|
||||
bind:open={openExitPortDialog}
|
||||
onSelect={(item) => (exitPort = item.port_code)}
|
||||
/>
|
||||
</Dialog.Root>
|
||||
@@ -305,31 +305,31 @@ export function getSidebarData(): SidebarData {
|
||||
items: [
|
||||
{
|
||||
title: m["sidebar.fractions.sitar"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/sitar",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.sitar_seventh_amendment"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/seventh-amendment",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.sitar_us"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/us",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.american"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/american",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.canadian"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/canadian",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.historical"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/tariff-fractions/historical",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.fractions.sectors"](),
|
||||
url: "#",
|
||||
url: "/dashboard/general_catalogs/sectors",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { writable } from "svelte/store";
|
||||
import { setContext } from "svelte";
|
||||
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
|
||||
import { type WithoutChild } from "$lib/utils.js";
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
import { writable } from 'svelte/store';
|
||||
import { setContext } from 'svelte';
|
||||
import { selectSearchContextKey, type SelectSearchContext } from './select-search-context';
|
||||
import { type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let { children, ...restProps }: WithoutChild<SelectPrimitive.RootProps> = $props();
|
||||
let {
|
||||
children,
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.RootProps> = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const query = writable("");
|
||||
const query = writable('');
|
||||
const openStore = writable(false);
|
||||
const context: SelectSearchContext = {
|
||||
query,
|
||||
@@ -23,11 +27,11 @@
|
||||
$effect(() => {
|
||||
openStore.set(open);
|
||||
if (!open) {
|
||||
query.set("");
|
||||
query.set('');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Root bind:open {...restProps}>
|
||||
<SelectPrimitive.Root bind:open bind:value={value as any} {...restProps}>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Root>
|
||||
|
||||
Reference in New Issue
Block a user