Merge branch 'development' into feature/catalog-importation
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
<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 { Loader2, Search, Info } from 'lucide-svelte';
|
||||
import { agencyTariffCodesApi, type AgencyTariffCode } from '$lib/api/dashboard/reference_data/agency_tariff_codes';
|
||||
|
||||
let { open = $bindable(false), onSelect }: { open: boolean; onSelect: (item: AgencyTariffCode) => void } =
|
||||
$props();
|
||||
|
||||
let items = $state<AgencyTariffCode[]>([]);
|
||||
let searchQ = $state('');
|
||||
let currentPage = $state(1);
|
||||
let totalItems = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let isLoading = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
searchQ = '';
|
||||
currentPage = 1;
|
||||
items = [];
|
||||
loadItems('', 1);
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
async function loadItems(search: string, page: number = currentPage) {
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await agencyTariffCodesApi.list(page, 50, search);
|
||||
|
||||
if (res.data) {
|
||||
if (page === 1) {
|
||||
items = [...res.data.items];
|
||||
} else {
|
||||
items = [...items, ...res.data.items];
|
||||
}
|
||||
totalItems = res.data.total;
|
||||
currentPage = page;
|
||||
hasMore = items.length < res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando agency tariff codes:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="z-[9999] w-full max-w-[95vw] sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>CATÁLOGO AGENCY TARIFF CODES (APHIS)</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="search_atc">Buscar:</Label>
|
||||
<div class="relative mt-1">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search_atc"
|
||||
bind:value={searchQ}
|
||||
placeholder="Filtre por programa, agencia o definición..."
|
||||
class="pl-8"
|
||||
oninput={() => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
items = [];
|
||||
currentPage = 1;
|
||||
loadItems(searchQ, 1);
|
||||
}, 400);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[400px] overflow-auto rounded-md border"
|
||||
onscroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop <= target.clientHeight + 50 &&
|
||||
hasMore &&
|
||||
!isLoading
|
||||
) {
|
||||
loadItems(searchQ, currentPage + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 z-10 border-b bg-background">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-semibold">Tariff Flag</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Agency</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Req/Maybe</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Program</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Definition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.id)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
onclick={() => {
|
||||
onSelect(item);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<td class="px-4 py-2 font-mono font-bold text-primary">{item.tariff_flag_code}</td>
|
||||
<td class="px-4 py-2">{item.agency_code}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class={item.requirement_level === 'R' ? 'text-destructive font-bold' : 'text-blue-500 font-bold'}>
|
||||
{item.requirement_level === 'R' ? 'Required' : 'Maybe Required'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 font-semibold">{item.program_code}</td>
|
||||
<td class="px-4 py-2 text-muted-foreground italic">{item.definition}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Cargando catálogo...
|
||||
</div>
|
||||
{:else}
|
||||
No se encontraron registros
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,171 @@
|
||||
<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 { Loader2, Search, AlertCircle } from 'lucide-svelte';
|
||||
import { cartaPorteApi, type CartaPorte } from '$lib/api/dashboard/reference_data/carta_porte';
|
||||
|
||||
let { open = $bindable(false), onSelect }: { open: boolean; onSelect: (item: CartaPorte) => void } =
|
||||
$props();
|
||||
|
||||
let items = $state<CartaPorte[]>([]);
|
||||
let searchQ = $state('');
|
||||
let currentPage = $state(1);
|
||||
let totalItems = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let isLoading = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
searchQ = '';
|
||||
currentPage = 1;
|
||||
items = [];
|
||||
// Solo cargar si hay búsqueda o si el usuario lo solicita para no saturar 30k registros
|
||||
loadItems('', 1);
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
async function loadItems(search: string, page: number = currentPage) {
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await cartaPorteApi.list(page, 50, search);
|
||||
|
||||
if (res.data) {
|
||||
if (page === 1) {
|
||||
items = [...res.data.items];
|
||||
} else {
|
||||
items = [...items, ...res.data.items];
|
||||
}
|
||||
totalItems = res.data.total;
|
||||
currentPage = page;
|
||||
hasMore = items.length < res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando carta porte:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="z-[9999] w-full max-w-[95vw] sm:max-w-6xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
CATÁLOGO CARTA PORTE (30,000+ REGISTROS)
|
||||
{#if totalItems > 0}
|
||||
<span class="text-xs font-normal text-muted-foreground">(Total: {totalItems})</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-primary/5 p-3 rounded-lg border border-primary/20 flex items-center gap-3">
|
||||
<AlertCircle class="h-5 w-5 text-primary" />
|
||||
<p class="text-[10px] leading-tight text-primary-foreground/80">
|
||||
Este catálogo es muy extenso. Use el buscador para encontrar códigos por <strong>descripción</strong> o <strong>código SAT</strong> de forma precisa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="search_cp">Buscador Inteligente:</Label>
|
||||
<div class="relative mt-1">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search_cp"
|
||||
bind:value={searchQ}
|
||||
placeholder="Escriba aquí para buscar..."
|
||||
class="pl-8 h-12 text-lg shadow-sm"
|
||||
oninput={() => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
items = [];
|
||||
currentPage = 1;
|
||||
loadItems(searchQ, 1);
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[400px] overflow-auto rounded-md border"
|
||||
onscroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop <= target.clientHeight + 50 &&
|
||||
hasMore &&
|
||||
!isLoading
|
||||
) {
|
||||
loadItems(searchQ, currentPage + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-10 border-b bg-background">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold w-16">ID</th>
|
||||
<th class="px-3 py-2 text-left font-semibold w-24">Código SAT</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Descripción Mercancía / Similares</th>
|
||||
<th class="px-3 py-2 text-center font-semibold w-24">Mat. Peligroso</th>
|
||||
<th class="px-3 py-2 text-left font-semibold w-24">Inicio Vigencia</th>
|
||||
<th class="px-3 py-2 text-left font-semibold w-24">Fin Vigencia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.id)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
onclick={() => {
|
||||
onSelect(item);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<td class="px-3 py-3 font-mono text-muted-foreground">{item.id}</td>
|
||||
<td class="px-3 py-3 font-mono font-bold group-hover:text-primary">{item.code}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="font-medium">{item.description}</div>
|
||||
{#if item.similar_words}
|
||||
<div class="text-[10px] text-muted-foreground mt-1 line-clamp-2" title={item.similar_words}>Similares: {item.similar_words}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-center">
|
||||
{#if item.is_hazardous}
|
||||
<span class="px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 text-[10px] font-bold border border-amber-200">SÍ</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-[10px]">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-xs opacity-80">{item.start_date || '-'}</td>
|
||||
<td class="px-3 py-3 text-xs opacity-80">{item.end_date || '-'}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<span class="text-sm">Buscando en más de 30k registros...</span>
|
||||
</div>
|
||||
{:else}
|
||||
No se han encontrado resultados. Intente con otra palabra.
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cerrar</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,147 @@
|
||||
<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 { Loader2, Search } from 'lucide-svelte';
|
||||
import { identifiersApi, type IdentifierCatalog } from '$lib/api/dashboard/reference_data/identifiers';
|
||||
|
||||
let { open = $bindable(false), onSelect }: { open: boolean; onSelect: (item: IdentifierCatalog) => void } =
|
||||
$props();
|
||||
|
||||
let items = $state<IdentifierCatalog[]>([]);
|
||||
let searchQ = $state('');
|
||||
let currentPage = $state(1);
|
||||
let totalItems = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let isLoading = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
searchQ = '';
|
||||
currentPage = 1;
|
||||
items = [];
|
||||
loadItems('', 1);
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
async function loadItems(search: string, page: number = currentPage) {
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await identifiersApi.list(page, 50, search);
|
||||
|
||||
if (res.data) {
|
||||
if (page === 1) {
|
||||
items = [...res.data.items];
|
||||
} else {
|
||||
items = [...items, ...res.data.items];
|
||||
}
|
||||
totalItems = res.data.total;
|
||||
currentPage = page;
|
||||
hasMore = items.length < res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando identificadores:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="z-[9999] w-full max-w-[95vw] sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>CATÁLOGO DE IDENTIFICADORES (APÉNDICE 8)</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="search_ident">Buscar:</Label>
|
||||
<div class="relative mt-1">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search_ident"
|
||||
bind:value={searchQ}
|
||||
placeholder="Clave o descripción..."
|
||||
class="pl-8"
|
||||
oninput={() => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
items = [];
|
||||
currentPage = 1;
|
||||
loadItems(searchQ, 1);
|
||||
}, 400);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[400px] overflow-auto rounded-md border"
|
||||
onscroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop <= target.clientHeight + 50 &&
|
||||
hasMore &&
|
||||
!isLoading
|
||||
) {
|
||||
loadItems(searchQ, currentPage + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-10 border-b bg-background">
|
||||
<tr>
|
||||
<th class="w-24 px-4 py-2 text-left font-semibold">Clave</th>
|
||||
<th class="w-24 px-4 py-2 text-left font-semibold">Nivel</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Descripción / Complemento</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.key)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
onclick={() => {
|
||||
onSelect(item);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<td class="px-4 py-2 font-mono font-bold text-primary">{item.key}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="px-2 py-0.5 rounded-full bg-muted text-[10px] font-bold">
|
||||
{item.level === 'G' ? 'Global' : 'Partida'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-xs uppercase">{item.description}</div>
|
||||
<div class="text-[10px] text-muted-foreground mt-1 italic">{item.complement}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-8 text-center text-muted-foreground">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Buscando identificadores...
|
||||
</div>
|
||||
{:else}
|
||||
No se encontraron registros
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,143 @@
|
||||
<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 { Loader2, Search } from 'lucide-svelte';
|
||||
import {
|
||||
licenseExceptionsApi,
|
||||
type LicenseException
|
||||
} from '$lib/api/dashboard/reference_data/license_exceptions';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: { open: boolean; onSelect: (item: LicenseException) => void } = $props();
|
||||
|
||||
let items = $state<LicenseException[]>([]);
|
||||
let searchQ = $state('');
|
||||
let currentPage = $state(1);
|
||||
let totalItems = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let isLoading = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
searchQ = '';
|
||||
currentPage = 1;
|
||||
items = [];
|
||||
loadItems('', 1);
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
async function loadItems(search: string, page: number = currentPage) {
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await licenseExceptionsApi.list(page, 50, search);
|
||||
|
||||
if (res.data) {
|
||||
if (page === 1) {
|
||||
items = [...res.data.items];
|
||||
} else {
|
||||
items = [...items, ...res.data.items];
|
||||
}
|
||||
totalItems = res.data.total;
|
||||
currentPage = page;
|
||||
hasMore = items.length < res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando excepciones de licencia:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="z-[9999] w-full max-w-[95vw] sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>SELECCIONAR SÍMBOLO DE EXCEPCIÓN</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="search_le">Buscar:</Label>
|
||||
<div class="relative mt-1">
|
||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search_le"
|
||||
bind:value={searchQ}
|
||||
placeholder="Clave o descripción..."
|
||||
class="pl-8"
|
||||
oninput={() => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
items = [];
|
||||
currentPage = 1;
|
||||
loadItems(searchQ, 1);
|
||||
}, 400);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-[400px] overflow-auto rounded-md border"
|
||||
onscroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop <= target.clientHeight + 50 &&
|
||||
hasMore &&
|
||||
!isLoading
|
||||
) {
|
||||
loadItems(searchQ, currentPage + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-10 border-b bg-background">
|
||||
<tr>
|
||||
<th class="w-24 px-4 py-2 text-left font-semibold">Clave</th>
|
||||
<th class="px-4 py-2 text-left font-semibold">Descripción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.key)}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
|
||||
onclick={() => {
|
||||
onSelect(item);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<td class="px-4 py-2 font-mono font-bold text-primary">{item.key}</td>
|
||||
<td class="px-4 py-2">{item.description}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="2" class="px-4 py-8 text-center text-muted-foreground">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Buscando...
|
||||
</div>
|
||||
{:else}
|
||||
No se encontraron registros
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -7,17 +7,19 @@
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
regimen?: string;
|
||||
operationType?: 'imp' | 'exp';
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
regimen = 'Temporal',
|
||||
operationType = 'imp' as 'imp' | 'exp',
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
regimen?: string;
|
||||
operationType?: 'imp' | 'exp';
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
} = $props();
|
||||
}: Props = $props();
|
||||
|
||||
let invoices = $state<Invoice[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { invoicesApi } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// Child components
|
||||
@@ -68,6 +68,9 @@
|
||||
if (editingItem.fa_data.omit_annex31 === undefined) {
|
||||
editingItem.fa_data.omit_annex31 = false;
|
||||
}
|
||||
if (editingItem.fa_data.discharge === undefined) {
|
||||
editingItem.fa_data.discharge = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +81,7 @@
|
||||
let showImportLinePicker = $state(false);
|
||||
let selectedImportInvoiceId = $state<number | null>(null);
|
||||
let selectedExportInvoiceId = $state<number | null>(null);
|
||||
let importInvoiceLines = $state<Item[]>([]);
|
||||
let importInvoiceLines = $state<ImportLineWithBalance[]>([]);
|
||||
let exportInvoiceLines = $state<Item[]>([]);
|
||||
let loadingImportLines = $state(false);
|
||||
let loadingExportLines = $state(false);
|
||||
@@ -88,8 +91,13 @@
|
||||
if (!companyId) return;
|
||||
loadingImportLines = true;
|
||||
try {
|
||||
const res = await itemsApi.listByInvoice(invoiceId, companyId);
|
||||
importInvoiceLines = res.data?.items ?? [];
|
||||
// Pass the export invoice date so consumption movements after that
|
||||
// date are not subtracted from the available balance.
|
||||
const asOfDate = invoice?.invoice_date
|
||||
? invoice.invoice_date.split('T')[0]
|
||||
: undefined;
|
||||
const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate);
|
||||
importInvoiceLines = res.data ?? [];
|
||||
} catch {
|
||||
importInvoiceLines = [];
|
||||
} finally {
|
||||
@@ -334,7 +342,7 @@
|
||||
value={editingItem.fa_data?.search_type || ''}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_type = v ?? undefined;
|
||||
editingItem.fa_data.search_type = v ?? 'Factura';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
|
||||
@@ -345,6 +353,7 @@
|
||||
<Select.Content>
|
||||
<Select.Item value="Factura">Factura</Select.Item>
|
||||
<Select.Item value="NumParte">NumParte</Select.Item>
|
||||
<Select.Item value="Clase">Clase</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
@@ -630,25 +639,111 @@
|
||||
|
||||
<!-- Diálogo para elegir línea (Impo) -->
|
||||
<Dialog.Root bind:open={showImportLinePicker}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Content class="max-w-4xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea de importación</Dialog.Title>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Solo se muestran líneas con saldo disponible
|
||||
</p>
|
||||
</Dialog.Header>
|
||||
<div class="max-h-[280px] overflow-y-auto py-2">
|
||||
{#each importInvoiceLines as lineItem}
|
||||
{@const num = lineItem.line_number ?? lineItem.id}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
|
||||
showImportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
Línea {num}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
|
||||
The inner div handles vertical scroll so sticky columns work
|
||||
independently from the horizontal scrollbar. -->
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-y-auto max-h-[500px]">
|
||||
{#if importInvoiceLines.every(l => !l.has_balance)}
|
||||
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
|
||||
No hay líneas con saldo disponible en esta factura.
|
||||
</p>
|
||||
{:else}
|
||||
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
|
||||
<thead class="sticky top-0 z-20">
|
||||
<tr class="border-b border-border bg-muted">
|
||||
<!-- sticky cols 1-3: left offsets match td widths below -->
|
||||
<th class="sticky left-0 z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[48px]">Línea</th>
|
||||
<th class="sticky left-[48px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[120px]">Factura</th>
|
||||
<th class="sticky left-[168px] z-20 bg-muted px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[88px]">Fecha</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Num. Parte</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap">Clase</th>
|
||||
<th class="px-2 py-2 text-left font-semibold text-muted-foreground whitespace-nowrap w-[180px]">Descripción</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Cant. Imp.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Temp.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Ret. Def.</th>
|
||||
<th class="px-2 py-2 text-right font-semibold text-muted-foreground whitespace-nowrap">Saldo Disp.</th>
|
||||
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Estatus</th>
|
||||
<th class="px-2 py-2 text-center font-semibold text-muted-foreground whitespace-nowrap">Sub.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each importInvoiceLines as lineItem}
|
||||
{#if lineItem.has_balance}
|
||||
<tr
|
||||
class="hover:bg-muted/50 cursor-pointer transition-colors group"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = lineItem.line_number;
|
||||
showImportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
<td class="sticky left-0 z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-semibold text-primary whitespace-nowrap w-[48px]">{lineItem.line_number}</td>
|
||||
<td class="sticky left-[48px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 font-mono whitespace-nowrap w-[120px]">{lineItem.invoice_number ?? '-'}</td>
|
||||
<td class="sticky left-[168px] z-10 bg-background group-hover:bg-muted/50 px-2 py-1.5 text-muted-foreground whitespace-nowrap w-[88px]">
|
||||
{lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 font-mono whitespace-nowrap">{lineItem.part_number ?? '-'}</td>
|
||||
<td class="px-2 py-1.5 whitespace-nowrap">{lineItem.class_code ?? '-'}</td>
|
||||
<td class="px-2 py-1.5 w-[180px] max-w-[180px] truncate text-muted-foreground" title={lineItem.description_spanish ?? ''}>
|
||||
{lineItem.description_spanish ?? '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
{lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
<span class="text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-amber-600 dark:text-amber-400">
|
||||
{lineItem.quantity_returned_temp != null ? lineItem.quantity_returned_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-blue-600 dark:text-blue-400">
|
||||
{lineItem.quantity_returned != null ? lineItem.quantity_returned.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap text-emerald-600 dark:text-emerald-400">
|
||||
{lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })}
|
||||
<span class="font-normal text-muted-foreground">{lineItem.unit_of_measure_code ?? ''}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center whitespace-nowrap">
|
||||
{#if lineItem.invoice_status === 'processed'}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
Procesada
|
||||
</span>
|
||||
{:else if lineItem.invoice_status === 'reversed'}
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:text-red-300">
|
||||
Revertida
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{lineItem.invoice_status ?? 'Pendiente'}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center whitespace-nowrap">
|
||||
{#if lineItem.is_subitem}
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/40 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:text-purple-300">
|
||||
Sub
|
||||
</span>
|
||||
{:else if lineItem.contains_subitems}
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 dark:text-indigo-300" title="{lineItem.subitem_count} subpartida(s)">
|
||||
{lineItem.subitem_count ?? 0} sub
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,11 +5,21 @@
|
||||
import { selectSearchContextKey, type SelectSearchContext } from './select-search-context';
|
||||
import { type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
// SelectPrimitive.RootProps is a discriminated union (single | multiple).
|
||||
// Spreading a discriminated union collapses conflicting members (e.g. onValueChange) to `never`.
|
||||
// We widen the props type so callers can pass either variant without hitting `never`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SelectRootProps = Omit<WithoutChild<SelectPrimitive.RootProps>, 'value' | 'onValueChange'> & {
|
||||
type?: 'single' | 'multiple';
|
||||
value?: string | string[];
|
||||
onValueChange?: (value: any) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
children,
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.RootProps> = $props();
|
||||
}: SelectRootProps = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const query = writable('');
|
||||
|
||||
Reference in New Issue
Block a user