DODA mejorado

This commit is contained in:
2026-02-11 13:45:20 -06:00
parent 573dd00056
commit 6e276cd8a9
11 changed files with 1243 additions and 398 deletions

View File

@@ -20,12 +20,14 @@
let loaded = $state(false);
let filteredItems = $derived(
items.filter(
(i) =>
i.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.broker_key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.license?.toLowerCase().includes(searchTerm.toLowerCase())
)
items.filter((i) => {
const search = searchTerm.toLowerCase();
return (
(i.name?.toLowerCase() || '').includes(search) ||
(i.broker_key?.toLowerCase() || '').includes(search) ||
(i.license?.toLowerCase() || '').includes(search)
);
})
);
$effect(() => {
@@ -38,10 +40,15 @@
if (!companyStore.activeCompany?.id) return;
loading = true;
try {
const res = await customsBrokersApi.list(companyStore.activeCompany.id.toString());
if (res.data?.items) {
items = res.data.items;
loaded = true;
const res = await customsBrokersApi.list(companyStore.activeCompany.id.toString(), 1, 100);
const data = (res.data || res) as any;
if (data) {
if (Array.isArray(data)) {
items = data;
} else if (data.items && Array.isArray(data.items)) {
items = data.items;
}
loaded = items.length > 0;
}
} catch (e) {
console.error('Error loading customs brokers:', e);

View File

@@ -25,9 +25,9 @@
const search = searchTerm.toLowerCase();
return (
!searchTerm ||
i.description?.toLowerCase().includes(search) ||
i.port_code?.toLowerCase().includes(search) ||
i.location_description?.toLowerCase().includes(search)
(i.description?.toLowerCase() || '').includes(search) ||
(i.port_code?.toLowerCase() || '').includes(search) ||
(i.location_description?.toLowerCase() || '').includes(search)
);
})
);
@@ -47,9 +47,14 @@
page: 1,
page_size: 100
});
if (res.data && res.data.items) {
items = res.data.items;
loaded = true;
const data = (res.data || res) as any;
if (data) {
if (Array.isArray(data)) {
items = data;
} else if (data.items && Array.isArray(data.items)) {
items = data.items;
}
loaded = items.length > 0;
}
} catch (e) {
console.error('Error loading ports:', e);

View File

@@ -3,6 +3,7 @@
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import { Search, Loader2, Truck } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
@@ -20,12 +21,14 @@
let loaded = $state(false);
let filteredItems = $derived(
items.filter(
(i) =>
i.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.transporter_key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.rfc?.toLowerCase().includes(searchTerm.toLowerCase())
)
items.filter((i) => {
const search = searchTerm.toLowerCase();
return (
(i.name?.toLowerCase() || '').includes(search) ||
(i.transporter_key?.toLowerCase() || '').includes(search) ||
(i.rfc?.toLowerCase() || '').includes(search)
);
})
);
$effect(() => {
@@ -38,14 +41,29 @@
if (!companyStore.activeCompany?.id) return;
loading = true;
try {
const res = await transportersApi.list(companyStore.activeCompany.id);
// Explicitly set pagination to avoid defaults
const res = await transportersApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: 100
});
const data = (res as any).data || res;
if (data && data.items) {
items = data.items;
if (data) {
if (Array.isArray(data)) {
items = data;
} else if (data.items) {
// Handle case where items might be an array or inside an object
items = Array.isArray(data.items) ? data.items : [];
}
loaded = true;
} else if ((res as any).error) {
console.error('API Error loading transporters:', (res as any).error);
toast.error('Error al cargar transportistas: ' + (res as any).error);
loaded = true;
}
} catch (e) {
console.error('Error loading transporters:', e);
toast.error('Error de conexión al cargar transportistas');
loaded = true;
} finally {
loading = false;
}

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Plus, Pencil, Trash2 } from 'lucide-svelte';
import { cn } from '$lib/utils';
interface Column {
header: string;
key: string;
render?: (value: any) => string | any;
}
let {
title = '',
columns = [],
data = [],
onAdd,
onEdit,
onDelete,
class: className = ''
}: {
title?: string;
columns: Column[];
data: any[];
onAdd?: () => void;
onEdit?: (item: any, index: number) => void;
onDelete?: (item: any, index: number) => void;
class?: string;
} = $props();
</script>
<div class={cn('space-y-4 rounded-xl border bg-card p-4 shadow-sm', className)}>
<div class="flex items-center justify-between">
{#if title}
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">{title}</h3>
{/if}
</div>
<div class="relative overflow-hidden rounded-md border bg-background">
<Table.Root>
<Table.Header class="bg-muted/50">
<Table.Row>
{#each columns as col}
<Table.Head class="h-10 px-4 text-xs font-semibold whitespace-nowrap"
>{col.header}</Table.Head
>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if data.length === 0}
<Table.Row>
<Table.Cell
colspan={columns.length}
class="h-24 text-center text-sm text-muted-foreground"
>
No hay registros.
</Table.Cell>
</Table.Row>
{:else}
{#each data as row, i}
<Table.Row class="group transition-colors hover:bg-muted/30">
{#each columns as col}
<Table.Cell class="px-4 py-2 text-sm whitespace-nowrap">
{#if col.render}
{col.render(row[col.key])}
{:else}
{row[col.key] ?? '-'}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1 px-3 text-xs font-medium">
<Plus class="h-3.5 w-3.5" />
Nuevo
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => {}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
>
<Pencil class="h-3.5 w-3.5" />
Editar
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
>
<Trash2 class="h-3.5 w-3.5" />
Borrar
</Button>
</div>
</div>

View File

@@ -1,14 +1,57 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { renderComponent } from '$lib/components/ui/data-table';
import { renderComponent, renderSnippet } from '$lib/components/ui/data-table';
import { createRawSnippet } from 'svelte';
import DataTableActions from './data-table-actions.svelte';
function formatDate(date?: string | null): string {
if (!date) return '-';
// Supposing created_at is an ISO string or similar
try {
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
} catch (e) {
return date;
}
}
export function createColumns(onSuccess?: () => void): ColumnDef<Doda>[] {
return [
{
accessorKey: 'integration_number',
header: 'No. Integración',
cell: ({ row }) => row.original.integration_number || 'N/A'
accessorKey: 'id',
header: 'Folio',
cell: ({ row }) => {
const numberSnippet = createRawSnippet<[{ number: number }]>((getProps) => {
const { number } = getProps();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number}</code>`
};
});
return renderSnippet(numberSnippet, { number: row.original.id });
}
},
{
accessorKey: 'created_at',
header: 'Fecha doda',
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getProps) => {
const { date } = getProps();
return {
render: () =>
`<div class="text-sm text-muted-foreground">${date}</div>`
};
});
return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) });
}
},
{
accessorKey: 'dispatch_customs',
header: 'Desp',
cell: ({ row }) => row.original.dispatch_customs || 'N/A'
},
{
accessorKey: 'patent',
@@ -17,18 +60,65 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Doda>[] {
},
{
accessorKey: 'pedimentos',
header: 'Pedimentos',
header: 'Pedimento(s)',
cell: ({ row }) => row.original.pedimentos || 'N/A'
},
{
accessorKey: 'doda_date',
header: 'Fecha',
cell: ({ row }) => row.original.doda_date || 'N/A'
accessorKey: 'shipments',
header: 'Remesa(s)',
cell: ({ row }) => row.original.shipments || 'N/A'
},
{
accessorKey: 'integration_number',
header: 'Integracion',
cell: ({ row }) => {
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getProps) => {
const { number } = getProps();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number || 'N/A'}</code>`
};
});
return renderSnippet(numberSnippet, { number: row.original.integration_number });
}
},
{
accessorKey: 'transaction_number',
header: 'No transaccion',
cell: ({ row }) => row.original.transaction_number || 'N/A'
},
{
accessorKey: 'transport_identification',
header: 'Id transporte',
cell: ({ row }) => row.original.transport_identification || 'N/A'
},
{
accessorKey: 'caat',
header: 'CAAT',
cell: ({ row }) => row.original.caat || 'N/A'
},
{
accessorKey: 'last_user',
header: 'Usuario',
cell: ({ row }) => row.original.last_user || 'N/A'
},
{
accessorKey: 'status',
header: 'Estatus',
cell: ({ row }) => row.original.status || 'N/A'
cell: ({ row }) => {
const status = row.original.status;
const statusSnippet = createRawSnippet<[{ status?: string | null }]>((getProps) => {
const { status } = getProps();
const colorClass = status === 'VALIDADO' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
return {
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${status || '-'}
</span>`
};
});
return renderSnippet(statusSnippet, { status });
}
},
{
id: 'actions',

View File

@@ -1,16 +1,15 @@
<script lang="ts" generics="TData, TValue">
import { type ColumnDef, getCoreRowModel, type RowSelectionState } from '@tanstack/table-core';
import { onMount } from 'svelte';
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
loading: boolean;
hasMore: boolean;
loadMore: () => void;
selectedId?: number | null;
onRowClick?: (row: TData) => void;
};
@@ -18,8 +17,9 @@
let {
data,
columns,
pageCount,
totalItems,
loading,
hasMore,
loadMore,
selectedId = null,
onRowClick
}: DataTableProps<TData, TValue> = $props();
@@ -37,26 +37,44 @@
}
},
enableRowSelection: true,
enableMultiRowSelection: false,
manualPagination: true,
pageCount: pageCount
enableMultiRowSelection: false
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="space-y-4">
<div class="rounded-md border">
<div class="w-full">
<div class="max-h-[600px] overflow-y-auto rounded-md border" bind:this={scrollContainer}>
<Table.Root>
<Table.Header>
<Table.Header class="bg-background">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="whitespace-nowrap">
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
@@ -73,12 +91,12 @@
<Table.Row
data-state={row.getIsSelected() && 'selected'}
onclick={() => onRowClick?.(row.original)}
class="cursor-pointer hover:bg-muted/50 transition-colors {row.getIsSelected()
? 'bg-primary/10'
: ''}"
class="cursor-pointer transition-colors {row.getIsSelected()
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell class="whitespace-nowrap">
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
@@ -90,32 +108,26 @@
</Table.Cell>
</Table.Row>
{/each}
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"
></div>
<span class="text-sm text-muted-foreground">Cargando más...</span>
</div>
{:else}
<div class="text-sm text-muted-foreground">Desplázate para cargar más</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Paginación -->
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,135 @@
<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, MapPin } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import {
customsSectionsApi,
type CustomsSection
} from '$lib/api/dashboard/reference_data/customs_sections';
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: CustomsSection) => void;
} = $props();
let items = $state<CustomsSection[]>([]);
let loading = $state(false);
let searchTerm = $state('');
let loaded = $state(false);
let filteredItems = $derived(
items.filter((i) => {
const search = searchTerm.toLowerCase();
return (
!searchTerm ||
(i.section_name?.toLowerCase() || '').includes(search) ||
(i.customs_code?.toLowerCase() || '').includes(search)
);
})
);
$effect(() => {
if (open && !loaded) {
loadItems();
}
});
async function loadItems() {
loading = true;
try {
// Customs sections are public reference data
// Reduced page_size to 100 to comply with backend constraints (le=100)
const res = await customsSectionsApi.list(1, 100);
const data = (res.data || res) as any;
if (data) {
// Handle both direct array and object-with-items wrapper
items = Array.isArray(data) ? data : data.items || [];
loaded = true;
} else if (res.error) {
console.error('API Error loading customs sections:', res.error);
toast.error('Error al cargar secciones: ' + res.error);
}
} catch (e) {
console.error('Error loading customs sections:', e);
toast.error('Error de conexión al cargar las secciones');
// Even on error, mark as loaded to prevent infinite loops, or handle with a retry button
loaded = true;
} finally {
loading = false;
}
}
function handleSelect(item: CustomsSection) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[80vh] flex-col sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>Seleccionar Sección Aduanera</Dialog.Title>
<Dialog.Description>Catálogo general de aduanas y secciones.</Dialog.Description>
</Dialog.Header>
<div class="relative my-2 w-full">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por descripción o código..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{#if loading}
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredItems.length === 0}
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
<p>No se encontraron registros.</p>
</div>
{:else}
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr class="border-b text-left">
<th class="w-[100px] p-3 font-medium text-muted-foreground">Código</th>
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
</tr>
</thead>
<tbody>
{#each filteredItems as item}
<tr
class="cursor-pointer border-b transition-colors hover:bg-accent/50"
onclick={() => handleSelect(item)}
>
<td class="p-3 font-mono font-bold text-primary">{item.customs_code}</td>
<td class="p-3">
<div class="flex items-center gap-2">
<MapPin class="h-3 w-3 text-muted-foreground" />
{item.section_name || '-'}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Dialog.Footer>
<div class="mr-auto self-center text-xs text-muted-foreground">
Mostrando {filteredItems.length} registros
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>