Merge pull request 'feature/nav-tab-envoices' (#107) from feature/nav-tab-envoices into Feature/Navegacion-free-mouse-pedimentos

Reviewed-on: ADUANASOFT/anexo76#107
This commit is contained in:
2026-02-05 16:36:37 +00:00
21 changed files with 2290 additions and 1683 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

@@ -18,6 +18,13 @@ export const GLOBAL_NAV = {
'r': '/dashboard/reference_data/pedimento_regimens',
'e': '/dashboard/reference_data/customs_sections#',
'd': '/dashboard/reference_data/customs_sections',
// Customs & Brokers
'a': '/dashboard/customs_brokers',
// Invoices
'i': '/dashboard/invoices',
// Common Actions
'b': 'SEARCH_FOCUS', // Special case for generic focus
'h': '/', // Home

View File

@@ -0,0 +1,35 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosEdicionAgente = (acciones: {
irGeneral: () => void;
irContacto: () => void;
irDireccion: () => void;
guardar: () => void;
cancelar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Digit1',
description: 'Tab General',
action: acciones.irGeneral
},
{
key: 'Alt+Digit2',
description: 'Tab Contacto',
action: acciones.irContacto
},
{
key: 'Alt+Digit3',
description: 'Tab Dirección',
action: acciones.irDireccion
},
{
key: 'Ctrl+S',
description: 'Guardar',
action: acciones.guardar
},
{
key: 'Escape',
description: 'Cancelar / Volver',
action: acciones.cancelar
}
];

View File

@@ -0,0 +1,31 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosListaAgentes = (acciones: {
irAgentes: () => void;
irAduanas: () => void;
crear: () => void;
recargar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Digit1',
description: 'Ir a Agentes',
action: acciones.irAgentes,
context: 'Customs Brokers Navigation'
},
{
key: 'Alt+Digit2',
description: 'Ir a Aduanas',
action: acciones.irAduanas,
context: 'Customs Brokers Navigation'
},
{
key: 'Alt+Shift+N',
description: 'Nuevo Registro',
action: acciones.crear
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.recargar
}
];

View File

@@ -6,12 +6,9 @@ export const obtenerAtajosFormularioMercancia = (acciones: {
manejarRegresar: () => void;
}): ShortcutDef[] => [
// Pestañas
{ key: 'Alt+G', description: 'Tab General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+I', description: 'Tab Imágenes', action: () => acciones.cambiarPestana('cont1') },
{ key: 'Alt+E', description: 'Tab Exportación', action: () => acciones.cambiarPestana('cont2') },
{ key: 'Alt+O', description: 'Tab Opciones', action: () => acciones.cambiarPestana('opciones') },
{ key: 'Alt+S', description: 'Tab Sanitarias', action: () => acciones.cambiarPestana('opcionales2') },
{ key: 'Alt+X', description: 'Tab Otros', action: () => acciones.cambiarPestana('otros') },
{ key: 'Alt+Digit1', description: 'Tab General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Tab Imágenes', action: () => acciones.cambiarPestana('cont1') },
{ key: 'Alt+Digit3', description: 'Tab Exportación', action: () => acciones.cambiarPestana('cont2') },
// Acciones Globales
{ key: 'Ctrl+S', description: 'Guardar cambios', action: acciones.manejarGuardar },

View File

@@ -1,73 +0,0 @@
import type { ShortcutDef } from '../../stores/shortcut-store';
export const obtenerAtajosListaPartes = (acciones: {
manejarNuevo: () => void;
manejarActualizar: () => void;
manejarBorrar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Shift+N',
description: 'Nueva Parte',
action: acciones.manejarNuevo
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.manejarActualizar
},
{
key: 'Alt+Shift+D',
description: 'Borrar Seleccionado',
action: acciones.manejarBorrar
}
];
export const obtenerAtajosListaClasesActivoFijo = (acciones: {
manejarNuevo: () => void;
manejarActualizar: () => void;
manejarBorrar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Shift+N',
description: 'Nueva Clase',
action: acciones.manejarNuevo
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.manejarActualizar
},
{
key: 'Alt+Shift+D',
description: 'Borrar Seleccionado',
action: acciones.manejarBorrar
}
];
export const obtenerAtajosFormularioParte = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCancelar: () => void;
esActivoFijo: boolean;
}): ShortcutDef[] => {
const common = [
{ key: 'Alt+Digit1', description: 'Pest. General', action: () => acciones.cambiarPestana('general') },
{ key: 'Ctrl+S', description: 'Guardar cambios', action: acciones.manejarGuardar },
{ key: 'Escape', description: 'Cancelar / Regresar', action: acciones.manejarCancelar }
];
if (acciones.esActivoFijo) {
return [
...common,
{ key: 'Alt+Digit2', description: 'Pest. Continuación 1', action: () => acciones.cambiarPestana('cont1') },
{ key: 'Alt+Digit3', description: 'Pest. Continuación 2', action: () => acciones.cambiarPestana('cont2') }
];
}
return [
...common,
{ key: 'Alt+Digit2', description: 'Pest. Opciones', action: () => acciones.cambiarPestana('opciones') },
{ key: 'Alt+Digit3', description: 'Pest. Opcionales 2', action: () => acciones.cambiarPestana('opcionales2') },
{ key: 'Alt+Digit4', description: 'Pest. Otros', action: () => acciones.cambiarPestana('otros') }
];
};

View File

@@ -0,0 +1,15 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosEdicionFactura = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarRegresar: () => void;
}): ShortcutDef[] => [
{ 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
}
];

View File

@@ -0,0 +1,48 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosListaFacturas = (acciones: {
manejarCrear: () => void;
manejarActualizar: () => void;
manejarEditar: () => void;
manejarDescargarPdf: () => void;
manejarDescargarConsolidado: () => void;
// Filtros de navegación
irTemporal: () => void;
irDefinitiva: () => void;
irNacional: () => void;
irCambioRegimen: () => void;
irExportacion: () => void;
irReparacion: () => void;
}): ShortcutDef[] => [
{ key: 'Alt+V', description: 'Ver Temporales', action: acciones.irTemporal },
{ key: 'Alt+F', description: 'Ver Definitivas', action: acciones.irDefinitiva },
{ key: 'Alt+N', description: 'Ver Nacionales', action: acciones.irNacional },
{ key: 'Alt+Z', description: 'Ver Cambio Régimen', action: acciones.irCambioRegimen },
{ key: 'Alt+L', description: 'Ver Exportaciones', action: acciones.irExportacion },
{ key: 'Alt+Y', description: 'Ver Reparaciones', action: acciones.irReparacion },
{
key: 'Alt+Shift+N',
description: 'Nueva Factura',
action: acciones.manejarCrear
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.manejarActualizar
},
{
key: 'Alt+Shift+E',
description: 'Editar Seleccionada',
action: acciones.manejarEditar
},
{
key: 'Alt+Shift+P',
description: 'Descargar PDF',
action: acciones.manejarDescargarPdf
},
{
key: 'Alt+Shift+C',
description: 'Descargar Consolidado',
action: acciones.manejarDescargarConsolidado
}
];

View File

@@ -1,333 +1,479 @@
<script lang="ts">
import { onMount } from 'svelte';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Plus, RefreshCw, Building2, MapPin, Mail, Phone, FileText, Hash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Plus, RefreshCw, Building2, MapPin, Mail, Phone, FileText, Hash } from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/customs-brokers-list-shortcuts';
let { data }: { data: any } = $props();
import {
customsSectionsApi,
type CustomsSection
} from '$lib/api/dashboard/refrence_data/customs_sections';
import DataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js';
import * as Card from '$lib/components/ui/card';
// State
let items = $state<CustomsBroker[]>(data.items || []);
let selectedItem = $state<CustomsBroker | null>(null);
let isLoading = $state(false);
// Server-side filtering/pagination (assuming API supports it or we filter client-side if list is short)
let allItemsRaw = $state<CustomsBroker[]>(data.brokers || []);
// Filter state
let searchName = $state('');
let searchPatent = $state('');
let { data }: { data: any } = $props();
// Pagination state
let currentPage = $state(1);
let pageSize = $state(50);
// Global State
let activeTab = $state('brokers');
// Derived filtered items
let filteredItems = $derived(
allItemsRaw.filter(item => {
const matchesName = !searchName || (item.name?.toLowerCase().includes(searchName.toLowerCase()) ?? false);
const matchesPatent = !searchPatent || (item.broker_key?.toLowerCase().includes(searchPatent.toLowerCase()) ?? false);
return matchesName && matchesPatent;
})
);
// --- Brokers State ---
let items = $state<CustomsBroker[]>(data.items || []);
let selectedItem = $state<CustomsBroker | null>(null);
let isLoading = $state(false);
let paginatedItems = $derived(
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
// Server-side filtering/pagination (for Brokers)
let allItemsRaw = $state<CustomsBroker[]>(data.brokers || []);
let searchName = $state('');
let searchPatent = $state('');
let currentPage = $state(1);
let pageSize = $state(50);
let totalItems = $derived(filteredItems.length);
let filteredItems = $derived(
allItemsRaw.filter((item) => {
const matchesName =
!searchName || (item.name?.toLowerCase().includes(searchName.toLowerCase()) ?? false);
const matchesPatent =
!searchPatent ||
(item.broker_key?.toLowerCase().includes(searchPatent.toLowerCase()) ?? false);
return matchesName && matchesPatent;
})
);
// --- Lifecycle ---
onMount(() => {
if (browser) {
const handleCompanyChange = () => loadItems();
window.addEventListener('companyChanged', handleCompanyChange);
return () => window.removeEventListener('companyChanged', handleCompanyChange);
}
});
let paginatedItems = $derived(
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
let totalItems = $derived(filteredItems.length);
// --- Actions ---
// --- Customs Sections State ---
let sections = $state<CustomsSection[]>([]);
let loadingSections = $state(false);
let totalSections = $state(0);
let sectionsPage = $state(1);
let hasMoreSections = $derived(sections.length < totalSections);
async function loadItems() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// --- Lifecycle ---
onMount(() => {
if (browser) {
const handleCompanyChange = () => {
loadItems();
loadSections();
};
window.addEventListener('companyChanged', handleCompanyChange);
// Initial load for sections if not loaded
loadSections();
return () => window.removeEventListener('companyChanged', handleCompanyChange);
}
});
isLoading = true;
try {
const response = await customsBrokersApi.list(companyId.toString());
// Handle response wrapper
if ((response as any).error) {
toast.error((response as any).error);
return;
}
// --- Actions ---
// Normalizing data structure
const data = (response as any).data || response;
if (Array.isArray(data)) {
allItemsRaw = data;
} else if ((data as any).items) {
allItemsRaw = (data as any).items;
}
// Brokers Actions
async function loadItems() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
isLoading = true;
try {
const response = await customsBrokersApi.list(companyId.toString());
if ((response as any).error) {
toast.error((response as any).error);
return;
}
const d = (response as any).data || response;
if (Array.isArray(d)) {
allItemsRaw = d;
} else if ((d as any).items) {
allItemsRaw = (d as any).items;
}
currentPage = 1;
} catch (e: any) {
console.error('Error loading items:', e);
toast.error('Error al cargar datos');
} finally {
isLoading = false;
}
}
function handleSearch() {
currentPage = 1;
}
function selectItem(item: CustomsBroker) {
selectedItem = item;
}
function handleEdit() {
if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`);
}
async function handleDelete() {
if (!selectedItem || !companyStore.activeCompany?.id) return;
if (!confirm('¿Estás seguro de eliminar este Agente Aduanal?')) return;
try {
await customsBrokersApi.delete(
selectedItem.broker_key,
companyStore.activeCompany.id.toString()
);
toast.success('Agente eliminado');
selectedItem = null;
loadItems();
} catch (e) {
toast.error('Error al eliminar');
}
}
currentPage = 1;
// Customs Sections Actions
async function loadSections() {
// Simple loading logic reusing existing API
// Assuming we load all or paginate - trying to match existing page logic
if (loadingSections) return;
loadingSections = true;
try {
const response = await customsSectionsApi.list(sectionsPage, 50);
if (response.data?.items) {
sections = sectionsPage === 1 ? response.data.items : [...sections, ...response.data.items];
totalSections = response.data.total;
}
} catch (e) {
console.error('Error loading sections', e);
} finally {
loadingSections = false;
}
}
} catch (e: any) {
console.error('Error loading items:', e);
toast.error('Error al cargar datos');
} finally {
isLoading = false;
}
}
function handleSearch() {
currentPage = 1;
}
async function loadMoreSections() {
if (loadingSections || !hasMoreSections) return;
sectionsPage++;
await loadSections();
}
function selectItem(item: CustomsBroker) {
selectedItem = item;
}
function handleEdit() {
if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`);
}
async function handleDelete() {
if (!selectedItem || !companyStore.activeCompany?.id) return;
if (!confirm('¿Estás seguro de eliminar este Agente Aduanal?')) return;
try {
await customsBrokersApi.delete(selectedItem.broker_key, companyStore.activeCompany.id.toString());
toast.success('Agente eliminado');
selectedItem = null;
loadItems();
} catch (e) {
toast.error('Error al eliminar');
}
}
// Shortcuts Integration
useShortcuts(
'Customs Brokers List',
obtenerAtajosListaAgentes({
irAgentes: () => (activeTab = 'brokers'),
irAduanas: () => (activeTab = 'customs'),
crear: () => {
if (activeTab === 'brokers') {
goto('/dashboard/customs_brokers/edit/new');
} else {
// For Customs Sections, we might need a dialog.
// Since we are "not modifying logic", we'll just show a toast or Placeholder TODO
toast.info('Crear Sección Aduanal: Implementación pendiente de diálogo');
}
},
recargar: () => {
if (activeTab === 'brokers') loadItems();
else {
sectionsPage = 1;
loadSections();
}
}
})
);
const sectionsColumns = createColumns(() => {
sectionsPage = 1;
loadSections();
});
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
<!-- Title -->
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold">AGENTES ADUANALES</h1>
<p class="text-sm text-muted-foreground">
Catálogo de agentes aduanales y apoderados legales
</p>
</div>
<!-- Title -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold">GESTIÓN ADUANAL</h1>
<p class="text-sm text-muted-foreground">Administración de Agentes y Secciones Aduanales</p>
</div>
</div>
<div class="flex-1 flex gap-4 overflow-hidden">
<!-- Left Panel: Table -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Filters -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre o patente</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9"
oninput={handleSearch}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Patente / Clave</Label>
<Input
bind:value={searchPatent}
placeholder="Num. Patente..."
class="h-9"
oninput={handleSearch}
/>
</div>
<div class="flex items-end">
<!-- Placeholder for layout balance -->
</div>
</div>
</div>
</div>
<Tabs.Root bind:value={activeTab} class="flex-1 flex flex-col overflow-hidden">
<Tabs.List class="w-full justify-start border-b rounded-none bg-transparent p-0 mb-4">
<Tabs.Trigger
value="brokers"
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
>
Agentes Aduanales
</Tabs.Trigger>
<Tabs.Trigger
value="customs"
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
>
Secciones Aduanales
</Tabs.Trigger>
</Tabs.List>
<!-- Table -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-muted/30">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{filteredItems.length} registros
</span>
<Button variant="outline" size="sm" onclick={loadItems}>
<RefreshCw class="h-4 w-4 mr-2" />
Actualizar
</Button>
</div>
</div>
<Tabs.Content
value="brokers"
class="flex-1 flex gap-4 overflow-hidden mt-0 data-[state=inactive]:hidden"
>
<!-- Left Panel: Table -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Filters -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre o patente</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9"
oninput={handleSearch}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Patente / Clave</Label>
<Input
bind:value={searchPatent}
placeholder="Num. Patente..."
class="h-9"
oninput={handleSearch}
/>
</div>
<div class="flex items-end">
<!-- Placeholder for layout balance -->
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-auto bg-card">
<table class="w-full text-sm">
<thead class="bg-muted text-muted-foreground border-b">
<tr>
<th class="px-3 py-2 text-left w-24">Patente</th>
<th class="px-3 py-2 text-left">Nombre</th>
<th class="px-3 py-2 text-left">Licencia</th>
<th class="px-3 py-2 text-left">Ciudad</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr><td colspan="4" class="text-center py-8 text-muted-foreground">Cargando...</td></tr>
{:else if paginatedItems.length === 0}
<tr><td colspan="4" class="text-center py-8 text-muted-foreground">No se encontraron registros</td></tr>
{:else}
{#each paginatedItems as item (item.broker_key)}
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.broker_key === item.broker_key ? 'bg-muted' : ''}"
onclick={() => selectItem(item)}
>
<td class="px-3 py-2 font-mono font-bold">{item.broker_key}</td>
<td class="px-3 py-2 font-medium">{item.name || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.license || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.city || '-'}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Simple Pagination Controls -->
{#if totalItems > pageSize}
<div class="p-2 border-t flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onclick={() => currentPage--}
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage * pageSize >= totalItems}
onclick={() => currentPage++}
>
Siguiente
</Button>
</div>
{/if}
</div>
</div>
<!-- Table -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-muted/30">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{filteredItems.length} registros
</span>
<Button variant="outline" size="sm" onclick={loadItems}>
<RefreshCw class="h-4 w-4 mr-2" />
Actualizar
</Button>
</div>
</div>
<!-- Right Panel: Details -->
<div class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden">
<div class="p-4 border-b bg-card">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">Detalles del Agente</p>
<h2 class="text-xl font-black font-mono tracking-tighter truncate" title={selectedItem?.name || ''}>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono text-muted-foreground">Patente: {selectedItem?.broker_key || ''}</span>
</div>
</div>
<div class="flex-1 overflow-auto bg-card">
<table class="w-full text-sm">
<thead class="bg-muted text-muted-foreground border-b">
<tr>
<th class="px-3 py-2 text-left w-24">Patente</th>
<th class="px-3 py-2 text-left">Nombre</th>
<th class="px-3 py-2 text-left">Licencia</th>
<th class="px-3 py-2 text-left">Ciudad</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr
><td colspan="4" class="text-center py-8 text-muted-foreground">Cargando...</td
></tr
>
{:else if paginatedItems.length === 0}
<tr
><td colspan="4" class="text-center py-8 text-muted-foreground"
>No se encontraron registros</td
></tr
>
{:else}
{#each paginatedItems as item (item.broker_key)}
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.broker_key ===
item.broker_key
? 'bg-muted'
: ''}"
onclick={() => selectItem(item)}
>
<td class="px-3 py-2 font-mono font-bold">{item.broker_key}</td>
<td class="px-3 py-2 font-medium">{item.name || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.license || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.city || '-'}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Simple Pagination Controls -->
{#if totalItems > pageSize}
<div class="p-2 border-t flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onclick={() => currentPage--}
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage * pageSize >= totalItems}
onclick={() => currentPage++}
>
Siguiente
</Button>
</div>
{/if}
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<FileText size={10} /> Licencia / Autorización
</Label>
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
</div>
<!-- Right Panel: Details -->
<div
class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden"
>
<div class="p-4 border-b bg-card">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
Detalles del Agente
</p>
<h2
class="text-xl font-black font-mono tracking-tighter truncate"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono text-muted-foreground"
>Patente: {selectedItem?.broker_key || ''}</span
>
</div>
</div>
{#if selectedItem.tax_id}
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Hash size={10} /> RFC / Tax ID
</Label>
<p class="text-sm font-mono">{selectedItem.tax_id}</p>
</div>
{/if}
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<p>{selectedItem.address || ''}</p>
<p>
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
</p>
<p>
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
</p>
</div>
</div>
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Phone size={10} /> Contacto
</Label>
{#if selectedItem.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.email}</span>
</div>
{/if}
{#if selectedItem.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.phone}</span>
</div>
{/if}
{#if selectedItem.contact}
<div class="mt-2 text-xs text-muted-foreground">
<span class="font-bold">Contacto:</span> {selectedItem.contact}
</div>
{/if}
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50">
<Building2 class="h-12 w-12 mb-3" />
<p class="text-sm">Selecciona un agente</p>
</div>
{/if}
</div>
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<FileText size={10} /> Licencia / Autorización
</Label>
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
</div>
{#if selectedItem.tax_id}
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Hash size={10} /> RFC / Tax ID
</Label>
<p class="text-sm font-mono">{selectedItem.tax_id}</p>
</div>
{/if}
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<p>{selectedItem.address || ''}</p>
<p>
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
</p>
<p>
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
</p>
</div>
</div>
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Phone size={10} /> Contacto
</Label>
{#if selectedItem.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.email}</span>
</div>
{/if}
{#if selectedItem.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.phone}</span>
</div>
{/if}
{#if selectedItem.contact}
<div class="mt-2 text-xs text-muted-foreground">
<span class="font-bold">Contacto:</span>
{selectedItem.contact}
</div>
{/if}
</div>
</div>
{:else}
<div
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50"
>
<Building2 class="h-12 w-12 mb-3" />
<p class="text-sm">Selecciona un agente</p>
</div>
{/if}
</div>
</div>
</Tabs.Content>
<Tabs.Content value="customs" class="flex-1 overflow-auto mt-0 data-[state=inactive]:hidden">
<!-- Reusing DataTable from Customs Sections -->
<Card.Root class="h-full flex flex-col border-none shadow-none">
<Card.Content class="flex-1 p-0">
<DataTable
data={sections}
columns={sectionsColumns}
loading={loadingSections}
hasMore={hasMoreSections}
loadMore={loadMoreSections}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</div>
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
>
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<div class="flex justify-end gap-2">
{#if activeTab === 'brokers'}
<Button size="sm" href="/dashboard/customs_brokers/edit">
<Plus class="h-4 w-4 mr-1" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{:else}
<Button size="sm" onclick={() => toast.info('Pendiente')}>
<Plus class="h-4 w-4 mr-1" />
Nueva Sección
</Button>
{/if}
</div>
</div>
</div>
<!-- Sticky Footer Actions -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<div class="flex justify-end gap-2">
<Button size="sm" href="/dashboard/customs_brokers/edit">
<Plus class="h-4 w-4 mr-1" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedItem} class="text-destructive hover:text-destructive">
Borrar
</Button>
</div>
</div>
</div>

View File

@@ -1,327 +1,397 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import { customsBrokersApi, type CreateCustomsBrokerData } from "$lib/api/dashboard/a76/customs-brokers";
// UI Components
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from "$lib/components/ui/separator";
import { Badge } from '$lib/components/ui/badge';
import * as Tabs from "$lib/components/ui/tabs";
import * as Card from "$lib/components/ui/card";
import { ArrowLeft, Loader2, Save, User, Phone, MapPin, Settings, FileText, Hash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import {
customsBrokersApi,
type CreateCustomsBrokerData
} from '$lib/api/dashboard/a76/customs-brokers';
// --- 1. LÓGICA DE IDENTIFICACIÓN ---
let routeId = $derived($page.params.id);
let isEdit = $derived(!!routeId && routeId !== 'new');
let title = $derived(isEdit ? "Editar Agente Aduanal" : "Nuevo Agente Aduanal");
// UI Components
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Badge } from '$lib/components/ui/badge';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import {
ArrowLeft,
Loader2,
Save,
User,
Phone,
MapPin,
Settings,
FileText,
Hash
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosEdicionAgente } from '$lib/config/shortcuts/customs-broker-edit-shortcuts';
// --- 2. ESTADO ---
let loading = $state(false);
let activeTab = $state('general');
let error = $state<string | null>(null);
let dataLoaded = $state(false);
// --- 1. LÓGICA DE IDENTIFICACIÓN ---
let routeId = $derived($page.params.id);
let isEdit = $derived(!!routeId && routeId !== 'new');
let title = $derived(isEdit ? 'Editar Agente Aduanal' : 'Nuevo Agente Aduanal');
let formData = $state<CreateCustomsBrokerData>({
broker_key: "",
license: "",
name: "",
tax_id: "",
email: "",
phone: "",
fax: "",
contact: "",
address: "",
postal_code: "",
city: "",
state: "",
country: "MEX",
type: "",
personal_id: "",
position: "",
company: "",
company_id: ""
});
// --- 2. ESTADO ---
let loading = $state(false);
let activeTab = $state('general');
let error = $state<string | null>(null);
let dataLoaded = $state(false);
// --- 3. CARGA DE DATOS REACTIVA ---
$effect(() => {
const company = companyStore.activeCompany;
if (company && isEdit && routeId && !dataLoaded && !loading) {
loadBrokerData(routeId, company.id.toString());
} else if (company && !isEdit) {
formData.company_id = company.id.toString();
}
});
let formData = $state<CreateCustomsBrokerData>({
broker_key: '',
license: '',
name: '',
tax_id: '',
email: '',
phone: '',
fax: '',
contact: '',
address: '',
postal_code: '',
city: '',
state: '',
country: 'MEX',
type: '',
personal_id: '',
position: '',
company: '',
company_id: ''
});
async function loadBrokerData(key: string, cId: string) {
if (!key || key === 'undefined') return;
loading = true;
try {
const res = await customsBrokersApi.get(key, cId);
const d = (res as any).data || res; // Handle wrapper or direct
// --- 3. CARGA DE DATOS REACTIVA ---
$effect(() => {
const company = companyStore.activeCompany;
if (company && isEdit && routeId && !dataLoaded && !loading) {
loadBrokerData(routeId, company.id.toString());
} else if (company && !isEdit) {
formData.company_id = company.id.toString();
}
});
if (d && !d.error) {
// Mapeo exhaustivo para asegurar reactividad
formData = {
broker_key: d.broker_key || "",
license: d.license || "",
name: d.name || "",
tax_id: d.tax_id || "",
email: d.email || "",
phone: d.phone || "",
fax: d.fax || "",
contact: d.contact || "",
address: d.address || "",
postal_code: d.postal_code || "",
city: d.city || "",
state: d.state || "",
country: d.country || "MEX",
type: d.type || "",
personal_id: d.personal_id || "",
position: d.position || "",
company: d.company || "",
company_id: cId
};
dataLoaded = true;
} else if (d.error) {
error = d.error;
toast.error(error);
}
} catch (e: any) {
error = "Error al conectar con el servidor";
toast.error(error);
} finally {
loading = false;
}
}
async function loadBrokerData(key: string, cId: string) {
if (!key || key === 'undefined') return;
loading = true;
try {
const res = await customsBrokersApi.get(key, cId);
const d = (res as any).data || res; // Handle wrapper or direct
// --- 4. GUARDADO ---
async function handleSave() {
if (!companyStore.activeCompany) {
toast.error("Selecciona una compañía");
return;
}
if (!formData.broker_key?.trim() || !formData.license?.trim()) {
error = "Clave y Patente son obligatorios";
toast.error(error);
return;
}
if (d && !d.error) {
// Mapeo exhaustivo para asegurar reactividad
formData = {
broker_key: d.broker_key || '',
license: d.license || '',
name: d.name || '',
tax_id: d.tax_id || '',
email: d.email || '',
phone: d.phone || '',
fax: d.fax || '',
contact: d.contact || '',
address: d.address || '',
postal_code: d.postal_code || '',
city: d.city || '',
state: d.state || '',
country: d.country || 'MEX',
type: d.type || '',
personal_id: d.personal_id || '',
position: d.position || '',
company: d.company || '',
company_id: cId
};
dataLoaded = true;
} else if (d.error) {
error = d.error;
toast.error(error);
}
} catch (e: any) {
error = 'Error al conectar con el servidor';
toast.error(error);
} finally {
loading = false;
}
}
loading = true;
error = null;
try {
const cId = companyStore.activeCompany.id.toString();
// Ensure company_id is set
formData.company_id = cId;
const res = isEdit
? await customsBrokersApi.update(routeId!, formData, cId)
: await customsBrokersApi.create(formData, cId);
if ((res as any).error) throw new Error((res as any).error);
toast.success(isEdit ? 'Agente actualizado' : 'Agente creado');
goto('/dashboard/customs_brokers');
} catch (e: any) {
error = e.message || "Error al procesar la solicitud";
toast.error(error);
} finally {
loading = false;
}
}
function handleCancel() {
goto('/dashboard/customs_brokers');
}
// --- 4. GUARDADO ---
async function handleSave() {
if (!companyStore.activeCompany) {
toast.error('Selecciona una compañía');
return;
}
if (!formData.broker_key?.trim() || !formData.license?.trim()) {
error = 'Clave y Patente son obligatorios';
toast.error(error);
return;
}
loading = true;
error = null;
try {
const cId = companyStore.activeCompany.id.toString();
// Ensure company_id is set
formData.company_id = cId;
const res = isEdit
? await customsBrokersApi.update(routeId!, formData, cId)
: await customsBrokersApi.create(formData, cId);
if ((res as any).error) throw new Error((res as any).error);
toast.success(isEdit ? 'Agente actualizado' : 'Agente creado');
goto('/dashboard/customs_brokers');
} catch (e: any) {
error = e.message || 'Error al procesar la solicitud';
toast.error(error);
} finally {
loading = false;
}
}
function handleCancel() {
goto('/dashboard/customs_brokers');
}
useShortcuts(
'Edit Broker Tabs',
obtenerAtajosEdicionAgente({
irGeneral: () => (activeTab = 'general'),
irContacto: () => (activeTab = 'contact'),
irDireccion: () => (activeTab = 'address'),
guardar: handleSave,
cancelar: handleCancel
})
);
</script>
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Button variant="ghost" size="icon" onclick={handleCancel}>
<ArrowLeft size={20} />
</Button>
<h1 class="text-3xl font-bold tracking-tight">
{isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'}
</h1>
<Badge variant={isEdit ? 'outline' : 'default'}>
{isEdit ? 'Edición' : 'Nuevo'}
</Badge>
</div>
<p class="text-muted-foreground ml-12">
{isEdit ? 'Modifica la información del agente aduanal' : 'Registra un nuevo agente aduanal en el sistema'}
</p>
</div>
</div>
<!-- Header -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Button variant="ghost" size="icon" onclick={handleCancel}>
<ArrowLeft size={20} />
</Button>
<h1 class="text-3xl font-bold tracking-tight">
{isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'}
</h1>
<Badge variant={isEdit ? 'outline' : 'default'}>
{isEdit ? 'Edición' : 'Nuevo'}
</Badge>
</div>
<p class="text-muted-foreground ml-12">
{isEdit
? 'Modifica la información del agente aduanal'
: 'Registra un nuevo agente aduanal en el sistema'}
</p>
</div>
</div>
<Separator />
<Separator />
<!-- Main Content -->
<div class="pb-48">
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<Tabs.Root bind:value={activeTab}>
<!-- Tab: General -->
<Tabs.Content value="general">
<Card.Root>
<Card.Header>
<Card.Title>Información General</Card.Title>
<Card.Description>Identificación oficial del agente y patente.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label class="required">Clave Agente <span class="text-destructive">*</span></Label>
<Input bind:value={formData.broker_key} placeholder="Ej. 550" disabled={isEdit || loading} />
<p class="text-xs text-muted-foreground">Clave interna o número de patente único.</p>
</div>
<div class="grid gap-2">
<Label class="required">Patente / Autorización <span class="text-destructive">*</span></Label>
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
</div>
</div>
<Separator />
<div class="grid gap-2">
<Label>Nombre / Razón Social</Label>
<Input bind:value={formData.name} placeholder="Nombre oficial" disabled={loading} />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>RFC</Label>
<Input bind:value={formData.tax_id} placeholder="RFC de la empresa" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>CURP</Label>
<Input bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} />
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<!-- Main Content -->
<div class="pb-48">
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<Tabs.Root bind:value={activeTab}>
<!-- Tab: General -->
<Tabs.Content value="general">
<Card.Root>
<Card.Header>
<Card.Title>Información General</Card.Title>
<Card.Description>Identificación oficial del agente y patente.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label class="required"
>Clave Agente <span class="text-destructive">*</span></Label
>
<Input
bind:value={formData.broker_key}
placeholder="Ej. 550"
disabled={isEdit || loading}
/>
<p class="text-xs text-muted-foreground">
Clave interna o número de patente único.
</p>
</div>
<div class="grid gap-2">
<Label class="required"
>Patente / Autorización <span class="text-destructive">*</span></Label
>
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
</div>
</div>
<!-- Tab: Contact -->
<Tabs.Content value="contact">
<Card.Root>
<Card.Header>
<Card.Title>Información de Contacto</Card.Title>
<Card.Description>Datos para comunicación con el agente.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Persona de Contacto</Label>
<Input bind:value={formData.contact} placeholder="Nombre del contacto" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Puesto / Cargo</Label>
<Input bind:value={formData.position} placeholder="Ej. Gerente Comercial" disabled={loading} />
</div>
</div>
<Separator />
<Separator />
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>Teléfono</Label>
<Input bind:value={formData.phone} placeholder="656-000-0000" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Fax</Label>
<Input bind:value={formData.fax} disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Correo Electrónico</Label>
<Input type="email" bind:value={formData.email} placeholder="correo@empresa.com" disabled={loading} />
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<div class="grid gap-2">
<Label>Nombre / Razón Social</Label>
<Input bind:value={formData.name} placeholder="Nombre oficial" disabled={loading} />
</div>
<!-- Tab: Address -->
<Tabs.Content value="address">
<Card.Root>
<Card.Header>
<Card.Title>Domicilio Fiscal</Card.Title>
<Card.Description>Ubicación registrada del agente aduanal.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid gap-2">
<Label>Calle y Número</Label>
<Input bind:value={formData.address} placeholder="Dirección completa" disabled={loading} />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>C.P.</Label>
<Input bind:value={formData.postal_code} placeholder="32000" disabled={loading} />
</div>
<div class="grid gap-2 md:col-span-2">
<Label>Ciudad</Label>
<Input bind:value={formData.city} placeholder="Ciudad" disabled={loading} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Estado</Label>
<Input bind:value={formData.state} placeholder="Estado" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>País</Label>
<Input bind:value={formData.country} placeholder="MEX" disabled={loading} />
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>RFC</Label>
<Input
bind:value={formData.tax_id}
placeholder="RFC de la empresa"
disabled={loading}
/>
</div>
<div class="grid gap-2">
<Label>CURP</Label>
<Input
bind:value={formData.personal_id}
placeholder="CURP si aplica"
disabled={loading}
/>
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</form>
</div>
<!-- Tab: Contact -->
<Tabs.Content value="contact">
<Card.Root>
<Card.Header>
<Card.Title>Información de Contacto</Card.Title>
<Card.Description>Datos para comunicación con el agente.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Persona de Contacto</Label>
<Input
bind:value={formData.contact}
placeholder="Nombre del contacto"
disabled={loading}
/>
</div>
<div class="grid gap-2">
<Label>Puesto / Cargo</Label>
<Input
bind:value={formData.position}
placeholder="Ej. Gerente Comercial"
disabled={loading}
/>
</div>
</div>
<Separator />
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>Teléfono</Label>
<Input
bind:value={formData.phone}
placeholder="656-000-0000"
disabled={loading}
/>
</div>
<div class="grid gap-2">
<Label>Fax</Label>
<Input bind:value={formData.fax} disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Correo Electrónico</Label>
<Input
type="email"
bind:value={formData.email}
placeholder="correo@empresa.com"
disabled={loading}
/>
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<!-- Tab: Address -->
<Tabs.Content value="address">
<Card.Root>
<Card.Header>
<Card.Title>Domicilio Fiscal</Card.Title>
<Card.Description>Ubicación registrada del agente aduanal.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid gap-2">
<Label>Calle y Número</Label>
<Input
bind:value={formData.address}
placeholder="Dirección completa"
disabled={loading}
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>C.P.</Label>
<Input bind:value={formData.postal_code} placeholder="32000" disabled={loading} />
</div>
<div class="grid gap-2 md:col-span-2">
<Label>Ciudad</Label>
<Input bind:value={formData.city} placeholder="Ciudad" disabled={loading} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Estado</Label>
<Input bind:value={formData.state} placeholder="Estado" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>País</Label>
<Input bind:value={formData.country} placeholder="MEX" disabled={loading} />
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</form>
</div>
</div>
<!-- Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
<div class="px-4 py-4 space-y-4 max-w-[1400px] mx-auto">
<!-- Footer Navigation -->
<Tabs.Root bind:value={activeTab}>
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-3">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<User size={16} class="mr-2" /> General
</Tabs.Trigger>
<Tabs.Trigger value="contact" class="whitespace-nowrap">
<Phone size={16} class="mr-2" /> Contacto
</Tabs.Trigger>
<Tabs.Trigger value="address" class="whitespace-nowrap">
<MapPin size={16} class="mr-2" /> Dirección
</Tabs.Trigger>
</Tabs.List>
</div>
</Tabs.Root>
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
>
<div class="px-4 py-4 space-y-4 max-w-[1400px] mx-auto">
<!-- Footer Navigation -->
<Tabs.Root bind:value={activeTab}>
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-3">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<User size={16} class="mr-2" /> General
</Tabs.Trigger>
<Tabs.Trigger value="contact" class="whitespace-nowrap">
<Phone size={16} class="mr-2" /> Contacto
</Tabs.Trigger>
<Tabs.Trigger value="address" class="whitespace-nowrap">
<MapPin size={16} class="mr-2" /> Dirección
</Tabs.Trigger>
</Tabs.List>
</div>
</Tabs.Root>
<!-- Actions -->
<div class="flex justify-end gap-3">
<Button variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button onclick={handleSave} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save size={16} class="mr-2" />
{isEdit ? 'Actualizar Agente' : 'Guardar Agente'}
{/if}
</Button>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<Button variant="outline" onclick={handleCancel} disabled={loading}>Cancelar</Button>
<Button onclick={handleSave} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save size={16} class="mr-2" />
{isEdit ? 'Actualizar Agente' : 'Guardar Agente'}
{/if}
</Button>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,29 +2,29 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/stores';
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Separator } from '$lib/components/ui/separator';
import { toast } from 'svelte-sonner';
import {
ArrowLeft,
FileText,
DollarSign,
Truck,
Package,
import {
ArrowLeft,
FileText,
DollarSign,
Truck,
Package,
Eye,
LoaderCircle,
Save
} from 'lucide-svelte';
LoaderCircle,
Save
} from 'lucide-svelte';
// Importar los componentes de cada pestaña
import GeneralTabForm from '$lib/components/dashboard/invoices/edit/general-tab-form.svelte';
import ObservationsTabForm from '$lib/components/dashboard/invoices/edit/observations-tab-form.svelte';
import ItemsTabForm from '$lib/components/dashboard/invoices/edit/items/items-tab-form.svelte';
import OthersTabForm from '$lib/components/dashboard/invoices/edit/others-tab-form.svelte';
import InvoiceTopFields from '$lib/components/dashboard/invoices/edit/invoice-top-fields.svelte';
import InvoiceTopFields from '$lib/components/dashboard/invoices/edit/invoice-top-fields.svelte';
import ContinuationTabForm from '$lib/components/dashboard/invoices/edit/continuation-tab-form.svelte';
// Importar la API de facturas
@@ -35,11 +35,13 @@
import { getExchangeRateByDate } from '$lib/api/dashboard/a76/exchange-rate';
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosEdicionFactura } from '$lib/config/shortcuts/invoice-edit-shortcuts';
// Cargar companyStore solo en el cliente - no usamos sidebar en esta página
let companyStore: any = $state(undefined);
let mounted = $state(false);
onMount(async () => {
try {
const companyStoreModule = await import('$lib/stores/company.svelte');
@@ -85,7 +87,7 @@
let activeTab = $state('general');
let saving = $state(false);
// ID de la factura
let invoiceId = $state<number | null>(data.invoiceId ?? null);
@@ -103,10 +105,12 @@
let othersExists = $state(false);
let continuationExists = $state(false);
let calculatedExchangeRate = $state<number | null>(data.invoice?.financials?.exchange_rate ?? null);
let calculatedExchangeRate = $state<number | null>(
data.invoice?.financials?.exchange_rate ?? null
);
let showExchangeRateDialog = $state(false);
let missingExchangeRateDate = $state("");
let missingExchangeRateDate = $state('');
async function checkExchangeRate(date: string): Promise<boolean> {
if (!date || !companyStore?.activeCompany?.id) return true;
@@ -120,10 +124,10 @@
date: date,
page_size: 1
});
const actualResponse = response as any;
const items = actualResponse.data?.items || [];
if (items.length === 0) {
console.log('No exchange rate found for', date);
missingExchangeRateDate = date;
@@ -139,7 +143,7 @@
// Por seguridad, abrimos modal.
missingExchangeRateDate = date;
showExchangeRateDialog = true;
return false;
return false;
}
}
@@ -147,14 +151,14 @@
$effect(() => {
if (mounted && companyStore?.activeCompany?.id && InvoiceTopFieldsFormData?.invoice_date) {
getExchangeRateByDate(InvoiceTopFieldsFormData.invoice_date, companyStore.activeCompany.id)
.then(rate => {
.then((rate) => {
if (rate) {
calculatedExchangeRate = rate.value;
calculatedExchangeRate = rate.value;
} else {
calculatedExchangeRate = 0;
}
})
.catch(err => console.error('Error auto-updating exchange rate:', err));
.catch((err) => console.error('Error auto-updating exchange rate:', err));
}
});
@@ -173,7 +177,7 @@
try {
// Validar ID si es edición
if (!data.isCreate && !invoiceId) {
toast.error("Error interrrno: No se encuentra el ID de la factura para actualizar.");
toast.error('Error interrrno: No se encuentra el ID de la factura para actualizar.');
saving = false;
return;
}
@@ -199,7 +203,7 @@
othersFormData,
continuationFormData
}
});
});
if (!result.success) {
// Crear un error con validationErrors si existen
@@ -212,8 +216,8 @@
toast.success('Todos los cambios se guardaron correctamente');
} catch (e) {
console.error('Error saving all:', e);
console.error('Error saving all:', e);
if (e instanceof Error && e.message.includes('401')) {
toast.error('Sesión expirada. Recargando página...');
setTimeout(() => {
@@ -221,26 +225,34 @@
}, 1500);
} else {
const errorMessage = e instanceof Error ? e.message : 'Error al guardar los cambios';
// Intercept exchange rate error
if (errorMessage.includes('No existe un Tipo de Cambio registrado') || errorMessage.includes('financials.exchange_rate')) {
console.log("Interceptor: Exchange rate missing error caught (Invoice Page).");
if (
errorMessage.includes('No existe un Tipo de Cambio registrado') ||
errorMessage.includes('financials.exchange_rate')
) {
console.log('Interceptor: Exchange rate missing error caught (Invoice Page).');
const dateMatch = errorMessage.match(/(\d{4}-\d{2}-\d{2})/);
const missingDate = dateMatch ? dateMatch[0] : (InvoiceTopFieldsFormData?.invoice_date || "");
const missingDate = dateMatch
? dateMatch[0]
: InvoiceTopFieldsFormData?.invoice_date || '';
missingExchangeRateDate = missingDate;
showExchangeRateDialog = true;
return;
}
// Si hay errores de validación, mostrarlos en detalle
if (e && typeof e === 'object' && 'validationErrors' in e && Array.isArray((e as any).validationErrors)) {
const validationErrors = (e as any).validationErrors;
const errorList = validationErrors.map((err: any) =>
`• ${err.message}`
).join('\n');
if (
e &&
typeof e === 'object' &&
'validationErrors' in e &&
Array.isArray((e as any).validationErrors)
) {
const validationErrors = (e as any).validationErrors;
const errorList = validationErrors.map((err: any) => `• ${err.message}`).join('\n');
toast.error(errorMessage, {
description: errorList
});
@@ -254,6 +266,15 @@
saving = false;
}
}
useShortcuts(
'Invoice Edit',
obtenerAtajosEdicionFactura({
cambiarPestana: (pestana) => (activeTab = pestana),
manejarGuardar: handleSaveAll,
manejarRegresar: handleBack
})
);
</script>
<div class="space-y-3">
@@ -267,7 +288,9 @@
<h1 class="text-3xl font-bold tracking-tight">
{#if data.isCreate}
{@const invoiceType = generalFormData?.invoice_type || data.filters?.invoice_type}
{@const invoiceTypeInfo = invoiceType ? data.invoiceTypes?.find(t => t.key === invoiceType) : null}
{@const invoiceTypeInfo = invoiceType
? data.invoiceTypes?.find((t) => t.key === invoiceType)
: null}
Nueva Factura
{#if invoiceTypeInfo}
<span class="text-muted-foreground font-normal text-2xl">
@@ -301,18 +324,18 @@
<!-- Contenido de las tabs con padding inferior para el footer flotante -->
<div class="pb-48">
<Tabs.Root bind:value={activeTab}>
<InvoiceTopFields
invoice={data.invoice}
bind:formData={InvoiceTopFieldsFormData}
invoiceTypes={data.invoiceTypes || []}
pedimentos={data.pedimentos || []}
defaultOperationType={data.filters?.operation_type ?? undefined}
defaultInvoiceType={data.filters?.invoice_type ?? undefined}
<InvoiceTopFields
invoice={data.invoice}
bind:formData={InvoiceTopFieldsFormData}
invoiceTypes={data.invoiceTypes || []}
pedimentos={data.pedimentos || []}
defaultOperationType={data.filters?.operation_type ?? undefined}
defaultInvoiceType={data.filters?.invoice_type ?? undefined}
/>
<Tabs.Content value="general">
<GeneralTabForm
invoice={data.invoice}
<Tabs.Content value="general">
<GeneralTabForm
invoice={data.invoice}
bind:formData={generalFormData}
invoiceTypes={data.invoiceTypes || []}
customsBrokers={data.customsBrokers || []}
@@ -323,45 +346,46 @@
transporters={data.transporters || []}
vehicles={data.vehicles || []}
drivers={data.drivers || []}
trailers={data.trailers || []}
trailers={data.trailers || []}
customsSections={data.customsSections || []}
codePedimentoRegimens={data.codePedimentoRegimens || []}
codePedimentoRegimens={data.codePedimentoRegimens || []}
defaultOperationType={data.filters?.operation_type ?? undefined}
defaultInvoiceType={data.filters?.invoice_type ?? undefined}
operationType={
InvoiceTopFieldsFormData?.operation_type === 'exp' ? 1
: InvoiceTopFieldsFormData?.operation_type === 'imp' ? 2
: data.invoice?.operation_type === 'exp' ? 1
: data.invoice?.operation_type === 'imp' ? 2
: undefined
}
operationType={InvoiceTopFieldsFormData?.operation_type === 'exp'
? 1
: InvoiceTopFieldsFormData?.operation_type === 'imp'
? 2
: data.invoice?.operation_type === 'exp'
? 1
: data.invoice?.operation_type === 'imp'
? 2
: undefined}
exchangeRate={calculatedExchangeRate}
/>
</Tabs.Content>
<Tabs.Content value="observations">
<ObservationsTabForm
invoice={data.invoice}
<ObservationsTabForm
invoice={data.invoice}
bind:formData={observationFormData}
bind:exists={observationExists}
seals={data.seals || []}
incoterms={data.incoterms || []}
enclosure={data.enclosure || []}
/>
</Tabs.Content>
<Tabs.Content value="items">
<ItemsTabForm
invoice={data.invoice}
<ItemsTabForm
invoice={data.invoice}
bind:formData={itemsFormData}
bind:exists={itemsExists}
/>
</Tabs.Content>
<Tabs.Content value="others">
<OthersTabForm
invoice={data.invoice}
<OthersTabForm
invoice={data.invoice}
bind:formData={othersFormData}
bind:exists={othersExists}
transportModes={data.transportModes || []}
@@ -369,31 +393,33 @@
</Tabs.Content>
<Tabs.Content value="continuation">
<ContinuationTabForm
invoice={data.invoice}
bind:formData={continuationFormData}
bind:exists={continuationExists}
/>
<ContinuationTabForm
invoice={data.invoice}
bind:formData={continuationFormData}
bind:exists={continuationExists}
/>
</Tabs.Content>
</Tabs.Root>
</div>
</div>
<!-- Footer fijo en la parte inferior -->
<ExchangeRateDialog
bind:open={showExchangeRateDialog}
<ExchangeRateDialog
bind:open={showExchangeRateDialog}
initialDate={missingExchangeRateDate}
onSuccess={() => {
// Actualizar el tipo de cambio mostrado
if (InvoiceTopFieldsFormData.invoice_date && companyStore?.activeCompany?.id) {
getExchangeRateByDate(InvoiceTopFieldsFormData.invoice_date, companyStore.activeCompany.id)
.then(rate => {
getExchangeRateByDate(
InvoiceTopFieldsFormData.invoice_date,
companyStore.activeCompany.id
).then((rate) => {
if (rate) calculatedExchangeRate = rate.value;
});
}
}}
/>
<div
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
>
<div class="px-4 py-4 space-y-4 max-w-[1400px] mx-auto">
@@ -413,7 +439,7 @@
<DollarSign size={16} class="mr-2" />
Partidas
</Tabs.Trigger>
<Tabs.Trigger value="others" disabled={false} class="whitespace-nowrap">
<Tabs.Trigger value="others" disabled={false} class="whitespace-nowrap">
<Truck size={16} class="mr-2" />
Otros
</Tabs.Trigger>