feature/nav-tab-items-nav-tables

This commit is contained in:
hreyes
2026-02-04 11:40:12 -06:00
parent 2c5d94722b
commit cbc3542ead
11 changed files with 601 additions and 349 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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