feature/nav-tab-items-nav-tables
This commit is contained in:
@@ -1,156 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Search, Loader2, User, Building2 } from "lucide-svelte";
|
||||
import { clientsProvidersApi, type ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, User, Building2 } from 'lucide-svelte';
|
||||
import {
|
||||
clientsProvidersApi,
|
||||
type ClientProvider
|
||||
} from '$lib/api/dashboard/a76/clients-providers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// --- PROPS Y BINDING ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean,
|
||||
onSelect: (client: ClientProvider) => void
|
||||
} = $props();
|
||||
// --- PROPS Y BINDING ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (client: ClientProvider) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO LOCAL ---
|
||||
let clients = $state<ClientProvider[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
// --- ESTADO LOCAL ---
|
||||
let clients = $state<ClientProvider[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
// Filtro reactivo local
|
||||
let filteredClients = $derived(
|
||||
clients.filter(c =>
|
||||
(c.client_or_provider === 'client' || c.client_or_provider === 'both') &&
|
||||
(c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.id.toString().includes(searchTerm))
|
||||
)
|
||||
);
|
||||
// Filtro reactivo local
|
||||
let filteredClients = $derived(
|
||||
clients.filter(
|
||||
(c) =>
|
||||
(c.client_or_provider === 'client' || c.client_or_provider === 'both') &&
|
||||
(c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.id.toString().includes(searchTerm))
|
||||
)
|
||||
);
|
||||
|
||||
// Efecto para cargar datos cuando se abre el modal
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
loadClients();
|
||||
}
|
||||
});
|
||||
// Efecto para cargar datos cuando se abre el modal
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
loadClients();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClients() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
// Petición a la API - Traer todos para filtrar localmente
|
||||
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000);
|
||||
async function loadClients() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
|
||||
// Normalización de respuesta
|
||||
const responseData = (res as any).data || res;
|
||||
loading = true;
|
||||
try {
|
||||
// Petición a la API - Traer todos para filtrar localmente
|
||||
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000);
|
||||
|
||||
if (responseData && responseData.items) {
|
||||
clients = responseData.items;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("La API no trajo items:", responseData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error cargando clientes:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// Normalización de respuesta
|
||||
const responseData = (res as any).data || res;
|
||||
|
||||
// --- FUNCIÓN DE SELECCIÓN ---
|
||||
function handleSelect(client: ClientProvider) {
|
||||
if (onSelect) {
|
||||
onSelect(client);
|
||||
}
|
||||
open = false; // Cerrar el modal
|
||||
}
|
||||
if (responseData && responseData.items) {
|
||||
clients = responseData.items;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn('La API no trajo items:', responseData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cargando clientes:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- FUNCIÓN DE SELECCIÓN ---
|
||||
function handleSelect(client: ClientProvider) {
|
||||
if (onSelect) {
|
||||
onSelect(client);
|
||||
}
|
||||
open = false; // Cerrar el modal
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={open}>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Cliente</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Busca y selecciona el cliente propietario de la parte.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Cliente</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Busca y selecciona el cliente propietario de la parte.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative w-full my-2">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por Nombre, RFC o ID..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full my-2">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por Nombre, RFC o ID..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if filteredClients.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No se encontraron clientes.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 backdrop-blur-sm">
|
||||
<tr class="text-left border-b">
|
||||
<th class="p-3 font-medium text-muted-foreground w-[60px]">ID</th>
|
||||
<th class="p-3 font-medium text-muted-foreground w-[130px]">RFC</th>
|
||||
<th class="p-3 font-medium text-muted-foreground">Razón Social</th>
|
||||
<th class="p-3 font-medium text-muted-foreground w-[100px] text-center">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredClients as client}
|
||||
<tr
|
||||
class="border-b hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onclick={() => handleSelect(client)}
|
||||
>
|
||||
<td class="p-3 font-mono text-xs">{client.id}</td>
|
||||
<td class="p-3 font-mono text-xs">{client.rfc}</td>
|
||||
<td class="p-3 font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if client.client_or_provider === 'client'}
|
||||
<User class="h-3 w-3 text-blue-500" />
|
||||
{:else}
|
||||
<Building2 class="h-3 w-3 text-purple-500" />
|
||||
{/if}
|
||||
{client.name}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3 text-center">
|
||||
{#if client.is_active}
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
|
||||
Baja
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if filteredClients.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No se encontraron clientes.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[60px]">ID</Table.Head>
|
||||
<Table.Head class="w-[130px]">RFC</Table.Head>
|
||||
<Table.Head>Razón Social</Table.Head>
|
||||
<Table.Head class="w-[100px] text-center">Estado</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filteredClients as client}
|
||||
<Table.Row class="cursor-pointer" onclick={() => handleSelect(client)}>
|
||||
<Table.Cell class="font-mono text-xs">{client.id}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{client.rfc}</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if client.client_or_provider === 'client'}
|
||||
<User class="h-3 w-3 text-blue-500" />
|
||||
{:else}
|
||||
<Building2 class="h-3 w-3 text-purple-500" />
|
||||
{/if}
|
||||
{client.name}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center">
|
||||
{#if client.is_active}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
|
||||
>
|
||||
Activo
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>
|
||||
Baja
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="text-xs text-muted-foreground self-center mr-auto">
|
||||
Mostrando {filteredClients.length} registro(s)
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<Dialog.Footer>
|
||||
<div class="text-xs text-muted-foreground self-center mr-auto">
|
||||
Mostrando {filteredClients.length} registro(s)
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { Loader2, Package, Save, X, FileText } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import type { Item } from '$lib/api/dashboard/a76/items';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosPestanasItemFa } from '$lib/config/shortcuts/invoice-item-fa-shortcuts';
|
||||
|
||||
// Child components
|
||||
import MainData from './main-data.svelte';
|
||||
@@ -37,6 +39,28 @@
|
||||
|
||||
// Acceso directo a la primera línea para evitar repeticiones en el HTML
|
||||
let line = $derived(editingItem.lines?.[0]);
|
||||
|
||||
let activeTab = $state('generales');
|
||||
|
||||
const tabMapping: Record<string, string> = {
|
||||
'tab1': 'generales',
|
||||
'tab2': 'continuacion',
|
||||
'tab3': 'series',
|
||||
'tab4': 'etiquetado',
|
||||
'tab5': 'identificadores'
|
||||
};
|
||||
|
||||
useShortcuts(
|
||||
'Invoice Item Form (Fixed Asset)',
|
||||
obtenerAtajosPestanasItemFa({
|
||||
cambiarPestana: (target) => {
|
||||
const tab = tabMapping[target];
|
||||
if (tab) activeTab = tab;
|
||||
},
|
||||
manejarGuardar: onSave,
|
||||
manejarCancelar: () => onCancel?.()
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={open}>
|
||||
@@ -99,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Root value="generales" class="w-full">
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-5 bg-zinc-100 dark:bg-zinc-800/50 rounded p-0.5 gap-0.5">
|
||||
<Tabs.Trigger value="generales" class="text-xs font-medium py-1.5 px-2 rounded transition-all data-[state=active]:bg-white dark:data-[state=active]:bg-zinc-700 data-[state=active]:shadow-sm data-[state=active]:text-black dark:data-[state=active]:text-white">
|
||||
General
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import type { Item } from '$lib/api/dashboard/a76/items';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosPestanasItemInv } from '$lib/config/shortcuts/invoice-item-inv-shortcuts';
|
||||
|
||||
let {
|
||||
let {
|
||||
open = $bindable(),
|
||||
isEditMode = false,
|
||||
editingItem = $bindable(),
|
||||
@@ -16,7 +18,7 @@
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving = false
|
||||
}: {
|
||||
}: {
|
||||
open: boolean;
|
||||
isEditMode?: boolean;
|
||||
editingItem: Partial<Item>;
|
||||
@@ -25,18 +27,43 @@
|
||||
onCancel?: () => void;
|
||||
isSaving?: boolean;
|
||||
} = $props();
|
||||
|
||||
let activeTab = $state('general');
|
||||
|
||||
const tabMapping: Record<string, string> = {
|
||||
tab1: 'general',
|
||||
tab2: 'clasificacion',
|
||||
tab3: 'cantidades',
|
||||
tab4: 'otros'
|
||||
};
|
||||
|
||||
useShortcuts(
|
||||
'Invoice Item Form (Inventory)',
|
||||
obtenerAtajosPestanasItemInv({
|
||||
cambiarPestana: (target) => {
|
||||
const tab = tabMapping[target];
|
||||
if (tab) activeTab = tab;
|
||||
},
|
||||
manejarGuardar: onSave,
|
||||
manejarCancelar: () => onCancel?.()
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={open}>
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario)</Sheet.Title>
|
||||
<Sheet.Title
|
||||
>{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} - SCAII (Inventario)</Sheet.Title
|
||||
>
|
||||
<Sheet.Description>
|
||||
{isEditMode ? 'Modifica los campos del inventario y guarda los cambios.' : 'Completa la información del nuevo item de inventario.'}
|
||||
{isEditMode
|
||||
? 'Modifica los campos del inventario y guarda los cambios.'
|
||||
: 'Completa la información del nuevo item de inventario.'}
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<Tabs.Root value="general" class="mt-6">
|
||||
<Tabs.Root bind:value={activeTab} class="mt-6">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="clasificacion">Clasificación</Tabs.Trigger>
|
||||
@@ -51,7 +78,8 @@
|
||||
<h4 class="text-sm font-medium">Información de la Factura (SCAII - Inventario)</h4>
|
||||
{#if !invoice?.id}
|
||||
<div class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded">
|
||||
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la factura.
|
||||
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la
|
||||
factura.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
@@ -69,7 +97,9 @@
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Sistema:</span>
|
||||
<span class="ml-2 font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded">SCAII (Inventory)</span>
|
||||
<span class="ml-2 font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded"
|
||||
>SCAII (Inventory)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -82,8 +112,8 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="order">Orden de Compra/Venta</Label>
|
||||
<Input
|
||||
id="order"
|
||||
<Input
|
||||
id="order"
|
||||
bind:value={editingItem.order}
|
||||
placeholder={invoice?.purchase_order || ''}
|
||||
/>
|
||||
@@ -249,7 +279,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="observations">Observaciones</Label>
|
||||
<textarea
|
||||
<textarea
|
||||
id="observations"
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Notas adicionales sobre el inventario..."
|
||||
@@ -260,9 +290,7 @@
|
||||
</Tabs.Root>
|
||||
|
||||
<Sheet.Footer class="mt-6 gap-2">
|
||||
<Button variant="outline" onclick={() => onCancel?.()} disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="outline" onclick={() => onCancel?.()} disabled={isSaving}>Cancelar</Button>
|
||||
<Button onclick={onSave} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import ItemSheetFa from './fa/item-sheet-fa.svelte';
|
||||
import ItemSheetInv from './inv/item-sheet-inv.svelte';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosListaPartidas } from '$lib/config/shortcuts/invoice-item-list-shortcuts';
|
||||
|
||||
let {
|
||||
let {
|
||||
invoice,
|
||||
formData = $bindable(),
|
||||
exists = $bindable()
|
||||
}: {
|
||||
}: {
|
||||
invoice: Invoice | null;
|
||||
formData?: any;
|
||||
exists?: boolean;
|
||||
@@ -28,11 +30,11 @@
|
||||
let displayedItems = $state<any[]>([]);
|
||||
let itemsPerPage = 20;
|
||||
let currentPage = $state(1);
|
||||
|
||||
|
||||
// Aplanar items en líneas para la tabla
|
||||
const flattenedLines = $derived(
|
||||
items.flatMap(item =>
|
||||
(item.lines || []).map(line => ({
|
||||
items.flatMap((item) =>
|
||||
(item.lines || []).map((line) => ({
|
||||
...line,
|
||||
item_id: item.id,
|
||||
reference_number: item.reference_number,
|
||||
@@ -77,7 +79,7 @@
|
||||
|
||||
async function loadItems() {
|
||||
if (!invoice?.id || !activeCompanyId) return;
|
||||
|
||||
|
||||
isLoadingItems = true;
|
||||
try {
|
||||
const response = await itemsApi.listByInvoice(invoice.id, activeCompanyId);
|
||||
@@ -85,12 +87,13 @@
|
||||
items = response.data.items || [];
|
||||
currentPage = 1;
|
||||
// Wait for derived state to update before loading items
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
loadMoreItems();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error loading items:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudieron cargar los items de la factura.';
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail || 'No se pudieron cargar los items de la factura.';
|
||||
toast.error('Error al cargar items', {
|
||||
description: errorMessage
|
||||
});
|
||||
@@ -109,8 +112,9 @@
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const threshold = 100;
|
||||
const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
|
||||
|
||||
const scrolledToBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
|
||||
|
||||
if (scrolledToBottom && !isLoadingMore && displayedItems.length < flattenedLines.length) {
|
||||
isLoadingMore = true;
|
||||
currentPage++;
|
||||
@@ -123,11 +127,11 @@
|
||||
if (!invoice?.id) {
|
||||
toast.warning('Factura no guardada', {
|
||||
description: 'Debes guardar la factura primero antes de agregar partidas.',
|
||||
duration: 5000,
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isEditMode = false;
|
||||
showItemSheet = true;
|
||||
// Auto-asignar valores desde la factura con estructura completa
|
||||
@@ -137,74 +141,76 @@
|
||||
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,
|
||||
lines: [
|
||||
{
|
||||
line_number: 1,
|
||||
// LineItem fields
|
||||
part_number: undefined,
|
||||
component_part_number: undefined,
|
||||
class_id: undefined,
|
||||
identifier: undefined,
|
||||
unit_of_measure: undefined,
|
||||
quantity_temp_export: undefined,
|
||||
quantity_returned: undefined,
|
||||
net_weight: undefined,
|
||||
gross_weight: undefined,
|
||||
package_key: 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,
|
||||
},
|
||||
}]
|
||||
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_key: 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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,9 +229,9 @@
|
||||
// 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];
|
||||
|
||||
|
||||
// Load class data
|
||||
if (line.class_id) {
|
||||
try {
|
||||
@@ -243,7 +249,7 @@
|
||||
console.error('Error loading class data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load part number data
|
||||
if (line.part_number_id) {
|
||||
try {
|
||||
@@ -261,14 +267,14 @@
|
||||
console.error('Error loading part data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load unit of measure data
|
||||
if (line.unit_of_measure) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api-sveltekit/units-of-measure/${line.unit_of_measure}`,
|
||||
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
const response = await fetch(`/api-sveltekit/units-of-measure/${line.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;
|
||||
@@ -278,7 +284,7 @@
|
||||
console.error('Error loading unit data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load country data (if needed)
|
||||
if (line.customs?.origin_country) {
|
||||
try {
|
||||
@@ -290,14 +296,15 @@
|
||||
const data = await response.json();
|
||||
if (data.items && data.items.length > 0) {
|
||||
const country = data.items[0];
|
||||
(line.customs as any).origin_country_name = country.description || country.description_en;
|
||||
(line.customs as any).origin_country_name =
|
||||
country.description || country.description_en;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading country data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load fraction data (if needed)
|
||||
if (line.customs?.fraction) {
|
||||
try {
|
||||
@@ -321,51 +328,59 @@
|
||||
// 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 => {
|
||||
item.lines = item.lines.map((line) => {
|
||||
const normalizedLine = { ...line };
|
||||
|
||||
|
||||
// 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,
|
||||
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 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,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return normalizedLine;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -377,32 +392,35 @@
|
||||
// Helper function to check if an object has any meaningful values
|
||||
function hasValues(obj: any): boolean {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
return Object.values(obj).some(val =>
|
||||
val !== undefined && val !== null && val !== '' &&
|
||||
!(typeof val === 'object' && !hasValues(val))
|
||||
return Object.values(obj).some(
|
||||
(val) =>
|
||||
val !== undefined &&
|
||||
val !== null &&
|
||||
val !== '' &&
|
||||
!(typeof val === 'object' && !hasValues(val))
|
||||
);
|
||||
}
|
||||
|
||||
// Clean nested data before sending to API
|
||||
function cleanLineData(line: any) {
|
||||
const cleaned: any = { ...line };
|
||||
|
||||
|
||||
// Helper function to convert to number or undefined
|
||||
const toNumberOrUndefined = (value: any): number | undefined => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const numValue = Number(value);
|
||||
return (!isNaN(numValue) && isFinite(numValue)) ? numValue : undefined;
|
||||
return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined;
|
||||
};
|
||||
|
||||
|
||||
// Convert integer fields
|
||||
cleaned.part_number = toNumberOrUndefined(cleaned.part_number);
|
||||
cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number);
|
||||
cleaned.class_id = toNumberOrUndefined(cleaned.class_id);
|
||||
cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure);
|
||||
cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit);
|
||||
|
||||
|
||||
// Remove display-only fields
|
||||
delete cleaned.class_code;
|
||||
delete cleaned.class_unit_of_measure;
|
||||
@@ -412,13 +430,13 @@
|
||||
delete cleaned.part_description_en;
|
||||
delete cleaned.unit_code;
|
||||
delete cleaned.unit_description;
|
||||
|
||||
|
||||
// Remove display-only fields from nested objects
|
||||
if (cleaned.customs) {
|
||||
delete cleaned.customs.origin_country_name;
|
||||
delete cleaned.customs.fraction_description;
|
||||
}
|
||||
|
||||
|
||||
// Remove empty nested objects
|
||||
if (!hasValues(cleaned.financial)) delete cleaned.financial;
|
||||
if (!hasValues(cleaned.quantity)) delete cleaned.quantity;
|
||||
@@ -426,18 +444,18 @@
|
||||
if (!hasValues(cleaned.description)) delete cleaned.description;
|
||||
if (!hasValues(cleaned.reference)) delete cleaned.reference;
|
||||
if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data;
|
||||
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function saveNewItem() {
|
||||
if (!invoice?.id || !activeCompanyId) return;
|
||||
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Clean lines data before sending
|
||||
const cleanedLines = (editingItem.lines || []).map(cleanLineData);
|
||||
|
||||
|
||||
const response = await itemsApi.create(activeCompanyId, {
|
||||
invoice_id: invoice.id,
|
||||
reference_number: editingItem.reference_number,
|
||||
@@ -446,7 +464,7 @@
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
});
|
||||
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
if ('error' in response) {
|
||||
// Manejar errores de validación (422)
|
||||
@@ -454,7 +472,7 @@
|
||||
const validationErrors = response.validationErrors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors,
|
||||
duration: 10000
|
||||
@@ -467,28 +485,29 @@
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
|
||||
showItemSheet = false;
|
||||
toast.success('Item creado', {
|
||||
description: 'El item se ha creado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error creating item:', error);
|
||||
|
||||
|
||||
// Manejar errores de validación (422)
|
||||
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
|
||||
const validationErrors = error.response.data.errors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
|
||||
toast.error('Error al crear item', {
|
||||
description: errorMessage
|
||||
});
|
||||
@@ -500,20 +519,20 @@
|
||||
|
||||
async function saveEditedItem() {
|
||||
if (!selectedItem?.id || !activeCompanyId) return;
|
||||
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
// Clean lines data before sending
|
||||
const cleanedLines = (editingItem.lines || []).map(cleanLineData);
|
||||
|
||||
const response = await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
|
||||
const response = await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location,
|
||||
lines: cleanedLines
|
||||
});
|
||||
|
||||
|
||||
// Verificar si hay errores de validación
|
||||
if ('error' in response) {
|
||||
// Manejar errores de validación (422)
|
||||
@@ -521,7 +540,7 @@
|
||||
const validationErrors = response.validationErrors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors,
|
||||
duration: 10000
|
||||
@@ -534,28 +553,29 @@
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
|
||||
showItemSheet = false;
|
||||
toast.success('Item actualizado', {
|
||||
description: 'El item se ha actualizado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error updating item:', error);
|
||||
|
||||
|
||||
// Manejar errores de validación (422)
|
||||
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
|
||||
const validationErrors = error.response.data.errors
|
||||
.map((err: any) => `• ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
|
||||
toast.error('Errores de validación', {
|
||||
description: validationErrors
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
|
||||
toast.error('Error al actualizar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
@@ -593,8 +613,9 @@
|
||||
}
|
||||
|
||||
// 4. Costo Unitario (al menos uno debe estar presente)
|
||||
const hasCost = line.financial?.unit_cost_usd ||
|
||||
line.financial?.unit_cost_mxn ||
|
||||
const hasCost =
|
||||
line.financial?.unit_cost_usd ||
|
||||
line.financial?.unit_cost_mxn ||
|
||||
line.financial?.unit_cost_capture;
|
||||
if (!hasCost) {
|
||||
missingFields.push('Costo Unitario (USD, MXN o Captura)');
|
||||
@@ -634,21 +655,22 @@
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!selectedItem?.id || !activeCompanyId) return;
|
||||
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await itemsApi.delete(selectedItem.id, activeCompanyId);
|
||||
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
|
||||
showDeleteDialog = false;
|
||||
toast.success('Item eliminado', {
|
||||
description: 'El item se ha eliminado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo eliminar el item. Intenta de nuevo.';
|
||||
const errorMessage =
|
||||
error?.response?.data?.detail || 'No se pudo eliminar el item. Intenta de nuevo.';
|
||||
toast.error('Error al eliminar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
@@ -665,6 +687,14 @@
|
||||
// Cerrar el sheet
|
||||
showItemSheet = false;
|
||||
}
|
||||
|
||||
useShortcuts(
|
||||
'Invoice Items List',
|
||||
obtenerAtajosListaPartidas({
|
||||
manejarAgregar: handleAdd,
|
||||
manejarActualizar: loadItems
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 grid-rows-1 gap-3">
|
||||
@@ -677,20 +707,20 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<div
|
||||
bind:this={tableContainer}
|
||||
onscroll={handleScroll}
|
||||
class="max-h-[500px] overflow-auto border rounded-md"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header class="bg-background">
|
||||
<Table.Row>
|
||||
<Table.Head>Línea</Table.Head>
|
||||
<Table.Head>P/S</Table.Head>
|
||||
<Table.Head>Clase</Table.Head>
|
||||
<Table.Head>Descripcion Clase</Table.Head>
|
||||
<Table.Row>
|
||||
<Table.Head>Línea</Table.Head>
|
||||
<Table.Head>P/S</Table.Head>
|
||||
<Table.Head>Clase</Table.Head>
|
||||
<Table.Head>Descripcion Clase</Table.Head>
|
||||
<Table.Head>Cant. Importada</Table.Head>
|
||||
<Table.Head>U.M.</Table.Head>
|
||||
<Table.Head>U.M.</Table.Head>
|
||||
<Table.Head>Preferencia</Table.Head>
|
||||
<Table.Head>Contiene Subpartida</Table.Head>
|
||||
<Table.Head>Partida Principal</Table.Head>
|
||||
@@ -714,8 +744,8 @@
|
||||
<Table.Cell>{item.quantity?.quantity || '0'}</Table.Cell>
|
||||
<Table.Cell>{item.unit_of_measure_code || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.reference_number || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.fa_data?.contains_subitems ? 'Sí' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="icon" variant="ghost" onclick={() => handleEdit(item)}>
|
||||
@@ -746,54 +776,58 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md p-3 space-y-3 col-start-4">
|
||||
|
||||
<div class="border rounded-md p-3 space-y-3 col-start-4">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Cantidades:</h4>
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
Partidas: <span class="text-blue-400">{items.length || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
Bultos: <span class="text-blue-400">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Importada: <span class="text-blue-400">{imported || 0}</span> <br>
|
||||
Peso neto: <span class="text-blue-400">{net_weight || 0}</span><br>
|
||||
Peso bruto: <span class="text-blue-400">{gross_weight || 0}</span> <br>
|
||||
<div>
|
||||
Bultos: <span class="text-blue-400">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Importada:<span class="text-blue-400">{imported || 0}</span> <br />
|
||||
Peso neto: <span class="text-blue-400">{net_weight || 0}</span><br />
|
||||
Peso bruto: <span class="text-blue-400">{gross_weight || 0}</span> <br />
|
||||
</div>
|
||||
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2">Valores de importacion:</h4>
|
||||
Dolares: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span> <br>
|
||||
Pesos: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2">
|
||||
Valores de importacion:
|
||||
</h4>
|
||||
Dolares:<span class="text-blue-400">0</span> <span class="text-red-400">USD</span> <br />
|
||||
Pesos: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br />
|
||||
De Captura: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span>
|
||||
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2 opacity-0">spacer</h4>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2 opacity-0">
|
||||
spacer
|
||||
</h4>
|
||||
|
||||
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span><br>
|
||||
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
|
||||
Aduana:<span class="text-blue-400">0</span> <span class="text-red-400">USD</span><br />
|
||||
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Sheet (Panel lateral para agregar/editar) -->
|
||||
<!-- Renderizar el componente apropiado según el sistema -->
|
||||
{#if invoiceSystem === 'fixed_asset'}
|
||||
<ItemSheetFa
|
||||
<ItemSheetFa
|
||||
bind:open={showItemSheet}
|
||||
{isEditMode}
|
||||
bind:editingItem={editingItem}
|
||||
bind:editingItem
|
||||
{invoice}
|
||||
onSave={saveItem}
|
||||
onCancel={handleCancelEdit}
|
||||
{isSaving}
|
||||
/>
|
||||
{:else}
|
||||
<ItemSheetInv
|
||||
<ItemSheetInv
|
||||
bind:open={showItemSheet}
|
||||
{isEditMode}
|
||||
bind:editingItem={editingItem}
|
||||
bind:editingItem
|
||||
{invoice}
|
||||
onSave={saveItem}
|
||||
onCancel={handleCancelEdit}
|
||||
@@ -811,7 +845,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showDeleteDialog = false} disabled={isSaving}>
|
||||
<Button variant="outline" onclick={() => (showDeleteDialog = false)} disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={confirmDelete} disabled={isSaving}>
|
||||
|
||||
@@ -109,6 +109,21 @@
|
||||
const isInput =
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
|
||||
// 0. SEARCH TO TABLE FLOW: ArrowDown from search input to first table row
|
||||
if (isInput && key === 'ArrowDown') {
|
||||
const dialog = target.closest('[role="dialog"]');
|
||||
if (dialog) {
|
||||
const firstRow = dialog.querySelector(
|
||||
'tr[tabindex="0"], [data-slot$="-item"][tabindex="0"]'
|
||||
) as HTMLElement;
|
||||
if (firstRow) {
|
||||
event.preventDefault();
|
||||
firstRow.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. HELP: F1
|
||||
if (key === 'F1') {
|
||||
event.preventDefault();
|
||||
@@ -192,18 +207,113 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. GLOBAL TABLE & SELECT NAVIGATION: Arrows / Enter / Tab
|
||||
const isRowOrItem =
|
||||
target.tagName === 'TR' ||
|
||||
target.getAttribute('data-slot') === 'table-row' ||
|
||||
target.getAttribute('data-slot') === 'select-item' ||
|
||||
target.closest('[data-slot="table-row"]') ||
|
||||
target.closest('[data-slot="select-item"]');
|
||||
|
||||
if (isRowOrItem) {
|
||||
const element = (
|
||||
target.getAttribute('data-slot')?.includes('-item') || target.tagName === 'TR'
|
||||
? target
|
||||
: target.closest('[data-slot$="-row"], [data-slot$="-item"]')
|
||||
) as HTMLElement;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
// Logic for UP/DOWN arrows
|
||||
if (key === 'ArrowDown') {
|
||||
const next = element.nextElementSibling as HTMLElement;
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
} else if (key === 'ArrowUp') {
|
||||
const prev = element.previousElementSibling as HTMLElement;
|
||||
if (prev) {
|
||||
event.preventDefault();
|
||||
prev.focus();
|
||||
}
|
||||
} else if (key === 'Enter' || key === ' ') {
|
||||
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
element.click();
|
||||
}
|
||||
} else if (key === 'Tab') {
|
||||
// Custom Tab navigation for rows/items if requested
|
||||
const siblings = Array.from(element.parentElement?.children || []);
|
||||
const index = siblings.indexOf(element);
|
||||
|
||||
if (shiftKey) {
|
||||
if (index > 0) {
|
||||
event.preventDefault();
|
||||
(siblings[index - 1] as HTMLElement).focus();
|
||||
}
|
||||
} else {
|
||||
if (index < siblings.length - 1) {
|
||||
event.preventDefault();
|
||||
(siblings[index + 1] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusIn(event: FocusEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
// Only scroll for input elements
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
// Only scroll for focusable interactive elements
|
||||
const isFocusableElement =
|
||||
['INPUT', 'TEXTAREA', 'SELECT', 'TR'].includes(target.tagName) ||
|
||||
['table-row', 'select-item', 'dropdown-menu-item'].includes(
|
||||
target.getAttribute('data-slot') || ''
|
||||
);
|
||||
|
||||
if (isFocusableElement) {
|
||||
// A small delay allows layout changes to settle
|
||||
setTimeout(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
// --- DYNAMIC INTERACTIVE OBSERVER ---
|
||||
// Automatically make any clickable row or item focusable
|
||||
$effect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
// Find TRs that look like they are clickable/interactive
|
||||
const trs = node.tagName === 'TR' ? [node] : Array.from(node.querySelectorAll('tr'));
|
||||
trs.forEach((tr) => {
|
||||
// If it has a cursor-pointer class or an onclick handler (complex to detect in Svelte,
|
||||
// so we use a broad heuristic: any TR inside a dialog's scroll area)
|
||||
const isInSelectionDialog =
|
||||
!!tr.closest('[role="dialog"]') && !!tr.closest('.overflow-y-auto');
|
||||
if (isInSelectionDialog && !tr.hasAttribute('tabindex')) {
|
||||
tr.setAttribute('tabindex', '0');
|
||||
// Add focus styles if it's a native TR
|
||||
if (!tr.getAttribute('data-slot')) {
|
||||
tr.classList.add(
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:bg-accent',
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-ring'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} onfocusin={handleFocusIn} />
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
tabindex="0"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
tabindex="0"
|
||||
class={cn(
|
||||
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
"focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -5,11 +5,11 @@ export const obtenerAtajosEdicionFactura = (acciones: {
|
||||
manejarGuardar: () => void;
|
||||
manejarRegresar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{ key: 'Alt+Q', description: 'Tab General', action: () => acciones.cambiarPestana('general') },
|
||||
{ key: 'Alt+O', description: 'Tab Observ.', action: () => acciones.cambiarPestana('observations') },
|
||||
{ key: 'Alt+A', description: 'Tab Partidas (Artículos)', action: () => acciones.cambiarPestana('items') },
|
||||
{ key: 'Alt+X', description: 'Tab Otros', action: () => acciones.cambiarPestana('others') },
|
||||
{ key: 'Alt+K', description: 'Tab Cont.', action: () => acciones.cambiarPestana('continuation') },
|
||||
{ key: 'Alt+Digit1', description: 'Tab General', action: () => acciones.cambiarPestana('general') },
|
||||
{ key: 'Alt+Digit2', description: 'Tab Observ.', action: () => acciones.cambiarPestana('observations') },
|
||||
{ key: 'Alt+Digit3', description: 'Tab Partidas', action: () => acciones.cambiarPestana('items') },
|
||||
{ key: 'Alt+Digit4', description: 'Tab Otros', action: () => acciones.cambiarPestana('others') },
|
||||
{ key: 'Alt+Digit5', description: 'Tab Cont.', action: () => acciones.cambiarPestana('continuation') },
|
||||
{ key: 'Ctrl+S', description: 'Guardar cambios', action: acciones.manejarGuardar },
|
||||
{ key: 'Escape', description: 'Regresar / Cancelar', action: acciones.manejarRegresar }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosPestanasItemFa = (acciones: {
|
||||
cambiarPestana: (target: string) => void;
|
||||
manejarGuardar: () => void;
|
||||
manejarCancelar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{ key: 'Alt+Digit1', description: 'Tab General', action: () => acciones.cambiarPestana('tab1') },
|
||||
{ key: 'Alt+Digit2', description: 'Tab Continuación', action: () => acciones.cambiarPestana('tab2') },
|
||||
{ key: 'Alt+Digit3', description: 'Tab Series', action: () => acciones.cambiarPestana('tab3') },
|
||||
{ key: 'Alt+Digit4', description: 'Tab Etiquetado', action: () => acciones.cambiarPestana('tab4') },
|
||||
{ key: 'Alt+Digit5', description: 'Tab IDs', action: () => acciones.cambiarPestana('tab5') },
|
||||
{ key: 'Ctrl+S', description: 'Guardar Partida', action: acciones.manejarGuardar },
|
||||
{ key: 'Escape', description: 'Cancelar', action: acciones.manejarCancelar }
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosPestanasItemInv = (acciones: {
|
||||
cambiarPestana: (target: string) => void;
|
||||
manejarGuardar: () => void;
|
||||
manejarCancelar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{ key: 'Alt+Digit1', description: 'Tab General', action: () => acciones.cambiarPestana('tab1') },
|
||||
{ key: 'Alt+Digit2', description: 'Tab Clasificación', action: () => acciones.cambiarPestana('tab2') },
|
||||
{ key: 'Alt+Digit3', description: 'Tab Cantidades', action: () => acciones.cambiarPestana('tab3') },
|
||||
{ key: 'Alt+Digit4', description: 'Tab Otros', action: () => acciones.cambiarPestana('tab4') },
|
||||
{ key: 'Ctrl+S', description: 'Guardar Partida', action: acciones.manejarGuardar },
|
||||
{ key: 'Escape', description: 'Cancelar', action: acciones.manejarCancelar }
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosListaPartidas = (acciones: {
|
||||
manejarAgregar: () => void;
|
||||
manejarActualizar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{
|
||||
key: 'Alt+Shift+N',
|
||||
description: 'Agregar Partida',
|
||||
action: acciones.manejarAgregar
|
||||
},
|
||||
{
|
||||
key: 'Alt+Shift+R',
|
||||
description: 'Actualizar Lista',
|
||||
action: acciones.manejarActualizar
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user