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:
Galindo97
2026-02-20 10:09:19 -06:00
127 changed files with 17010 additions and 9437 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import {
createHistoricalFraction,
updateHistoricalFraction,
type HistoricalFraction,
type HistoricalFractionCreate,
type HistoricalFractionUpdate
} from '$lib/api/dashboard/a76/general_catalogs/historical-tariff-fractions';
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
import { Loader2 } from 'lucide-svelte';
let {
open = $bindable(false),
fraction = null, // If null, create mode. If set, edit mode.
onSuccess
}: {
open: boolean;
fraction?: HistoricalFraction | null;
onSuccess: () => void;
} = $props();
let isLoading = $state(false);
// Form fields
let historicalFractionCode = $state('');
let unitOfMeasureCode = $state('');
let country = $state('');
let fractionType = $state('');
let sector = $state('');
let importTaxRate = $state('');
let exportTaxRate = $state('');
let publicationDate = $state('');
let endDate = $state('');
let isImmex = $state(false);
let normalTemporality = $state(false);
let servicesTemporality = $state(false);
let certifiedTemporality = $state(false);
let byLog = $state(false);
// Load data on open/fraction change
$effect(() => {
if (open) {
if (fraction) {
// Edit mode
historicalFractionCode = fraction.historical_fraction || '';
unitOfMeasureCode = fraction.unit_of_measure_code || '';
country = fraction.country || '';
fractionType = fraction.fraction_type || '';
sector = fraction.sector || '';
importTaxRate = fraction.import_tax_rate?.toString() || '';
exportTaxRate = fraction.export_tax_rate?.toString() || '';
publicationDate = fraction.publication_date ? fraction.publication_date.split('T')[0] : '';
endDate = fraction.end_date ? fraction.end_date.split('T')[0] : '';
isImmex = fraction.is_immex || false;
normalTemporality = fraction.normal_temporality || false;
servicesTemporality = fraction.services_temporality || false;
certifiedTemporality = fraction.certified_temporality || false;
byLog = fraction.by_log || false;
} else {
// Create mode - reset
historicalFractionCode = '';
unitOfMeasureCode = '';
country = '';
fractionType = '';
sector = '';
importTaxRate = '';
exportTaxRate = '';
publicationDate = '';
endDate = '';
isImmex = false;
normalTemporality = false;
servicesTemporality = false;
certifiedTemporality = false;
byLog = false;
}
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Validation
if (!historicalFractionCode) {
toast.error('La fracción es requerida');
return;
}
isLoading = true;
try {
const importRateNum = importTaxRate ? parseFloat(importTaxRate) : undefined;
const exportRateNum = exportTaxRate ? parseFloat(exportTaxRate) : undefined;
const baseData = {
historical_fraction: historicalFractionCode,
unit_of_measure_code: unitOfMeasureCode || null,
country: country || null,
fraction_type: fractionType || null,
sector: sector || null,
import_tax_rate: importRateNum,
export_tax_rate: exportRateNum,
publication_date: publicationDate || null,
end_date: endDate || null,
is_immex: isImmex,
normal_temporality: normalTemporality,
services_temporality: servicesTemporality,
certified_temporality: certifiedTemporality,
by_log: byLog
};
if (fraction) {
// Update
const updateData: HistoricalFractionUpdate = baseData;
await updateHistoricalFraction(companyId, fraction.id, updateData);
toast.success('Fracción actualizada correctamente');
} else {
// Create
const createData: HistoricalFractionCreate = {
...baseData,
historical_fraction: historicalFractionCode // Required in create
};
await createHistoricalFraction(companyId, createData);
toast.success('Fracción creada correctamente');
}
onSuccess();
open = false;
} catch (error) {
console.error('Error saving historical fraction:', error);
toast.error('Error al guardar la fracción');
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>{fraction ? 'Editar' : 'Crear'} Fracción Histórica</Dialog.Title>
<Dialog.Description>
{fraction
? 'Modifica los detalles de la fracción seleccionada.'
: 'Ingresa los datos para la nueva fracción.'}
</Dialog.Description>
</Dialog.Header>
<div class="grid max-h-[70vh] gap-4 overflow-y-auto py-4 pr-2">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fraction">Fracción</Label>
<Input
id="fraction"
bind:value={historicalFractionCode}
placeholder="Ej. 01010101"
maxlength={8}
/>
</div>
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
<Input id="unit" bind:value={unitOfMeasureCode} placeholder="Ej. 01" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country">País</Label>
<Input id="country" bind:value={country} placeholder="Ej. MEX" />
</div>
<div class="space-y-2">
<Label for="type">Tipo</Label>
<Input id="type" bind:value={fractionType} placeholder="Ej. General" maxlength={7} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sector">Sector</Label>
<Input id="sector" bind:value={sector} placeholder="Sectores..." maxlength={5} />
</div>
<div class="space-y-2">
<Label for="by-log">Por Bitácora</Label>
<div class="flex items-center space-x-2 pt-2">
<Switch id="by-log" bind:checked={byLog} />
<span class="text-sm text-muted-foreground">{byLog ? 'Sí' : 'No'}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="import-tax">Tasa IGI (%)</Label>
<Input
id="import-tax"
type="number"
step="0.01"
bind:value={importTaxRate}
placeholder="0.00"
/>
</div>
<div class="space-y-2">
<Label for="export-tax">Tasa IGE (%)</Label>
<Input
id="export-tax"
type="number"
step="0.01"
bind:value={exportTaxRate}
placeholder="0.00"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="pub-date">Fecha Publicación</Label>
<Input id="pub-date" type="date" bind:value={publicationDate} />
</div>
<div class="space-y-2">
<Label for="end-date">Fecha Fin</Label>
<Input id="end-date" type="date" bind:value={endDate} />
</div>
</div>
<div class="grid grid-cols-2 gap-4 border-t pt-4">
<div class="flex items-center justify-between space-x-2">
<Label for="is-immex">IMMEX</Label>
<Switch id="is-immex" bind:checked={isImmex} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="normal-temp">Temp. Normal</Label>
<Switch id="normal-temp" bind:checked={normalTemporality} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center justify-between space-x-2">
<Label for="services-temp">Temp. Servicios</Label>
<Switch id="services-temp" bind:checked={servicesTemporality} />
</div>
<div class="flex items-center justify-between space-x-2">
<Label for="certified-temp">Temp. Certificada</Label>
<Switch id="certified-temp" bind:checked={certifiedTemporality} />
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button onclick={handleSubmit} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Guardar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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