Merge branch 'development' into feature/catalog-importation

This commit is contained in:
2026-03-18 13:40:33 -05:00
74 changed files with 33790 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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