Merge pull request 'fix/doda' (#129) from fix/doda into development

Reviewed-on: ADUANASOFT/anexo76#129
This commit is contained in:
2026-02-11 23:28:35 +00:00
12 changed files with 1354 additions and 403 deletions

View File

@@ -20,7 +20,7 @@ export interface DodaContainer {
container_line: number;
container_value?: string;
seals?: string;
seals_detail?: DodaContainerSeal[];
seals_detail?: DodaContainerSeal[];
}
export interface DodaContainerCreate {
@@ -79,13 +79,13 @@ export interface Doda {
dispatch_customs?: string;
customs_sections?: string;
patent?: string;
pedimentos?: string;
pedimentos?: string;
caat?: string;
transport_identification?: string;
fast_id?: string;
operation_type?: string;
status?: string;
// Relaciones
containers?: DodaContainer[];
american_pedimentos?: DodaAmericanPedimento[];
@@ -95,7 +95,7 @@ export interface Doda {
tenant_id?: string;
created_at?: string;
updated_at?: string;
// Campos extra del formulario que vimos en el frontend
selected?: boolean;
user_selected?: string;
@@ -152,7 +152,7 @@ export interface DodaCreate {
unique_badge_number?: string;
}
export interface DodaUpdate extends Partial<DodaCreate> {}
export interface DodaUpdate extends Partial<DodaCreate> { }
export interface DodaListResponse {
items: Doda[];
@@ -187,7 +187,7 @@ export async function getDoda(id: number, companyId?: number): Promise<Doda> {
if (companyId) {
params.append('company_id', companyId.toString());
}
const response = await api.get(`/v1/a76/doda/${id}/?${params.toString()}`);
const response = await api.get(`/v1/a76/doda/${id}/detail?${params.toString()}`);
return response.data;
}
@@ -202,5 +202,5 @@ export async function updateDoda(id: number, data: DodaUpdate, companyId: number
}
export async function deleteDoda(id: number, companyId: number): Promise<void> {
await api.delete(`/v1/a76/doda/${id}/?company_id=${companyId}`);
await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
}

View File

@@ -5,25 +5,25 @@
import { api } from '$lib/api';
export interface CustomsSection {
customs_code: string;
section_name: string;
customs_code: string;
section_name: string;
}
export interface CustomsSectionListResponse {
items: CustomsSection[];
total: number;
page: number;
page_size: number;
items: CustomsSection[];
total: number;
page: number;
page_size: number;
}
export interface CreateCustomsSectionData {
customs_code: string;
section_name: string;
customs_code: string;
section_name: string;
}
export interface UpdateCustomsSectionData {
customs_code?: string;
section_name?: string;
customs_code?: string;
section_name?: string;
}
/**
@@ -37,7 +37,6 @@ export const customsSectionsApi = {
*/
list: (page = 1, pageSize = 50) =>
api.get<CustomsSectionListResponse>(
// CORREGIDO: Añadido '/' antes de los parámetros
`/v1/public/reference_data/customs-sections/?page=${page}&page_size=${pageSize}`
),
@@ -45,7 +44,7 @@ export const customsSectionsApi = {
* Obtiene una sección aduanera por código
* @param customs_code - Código de la sección aduanera
*/
get: (customs_code: string) =>
get: (customs_code: string) =>
// CORREGIDO: Añadido '/' final
api.get<CustomsSection>(`/v1/public/reference_data/customs-sections/${customs_code}/`),
@@ -70,7 +69,7 @@ export const customsSectionsApi = {
* Elimina una sección aduanera
* @param customs_code - Código de la sección aduanera a eliminar
*/
delete: (customs_code: string) =>
delete: (customs_code: string) =>
// CORREGIDO: Añadido '/' después del código
api.delete(`/v1/public/reference_data/customs-sections/${customs_code}/`)
};

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>

View File

@@ -2,101 +2,362 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getDodas, deleteDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import * as Card from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import {
Plus,
RefreshCw,
Trash2,
Pencil,
Search,
RotateCcw,
FileText,
LayoutGrid,
Printer
} from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { companyStore } from '$lib/stores/company.svelte';
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import CreateEditDialog from '$lib/components/dashboard/general_catalogs/doda/create-edit-dialog.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
import { invalidateAll } from '$app/navigation';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/list';
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
let { data } = $props();
let dialogOpen = $state(false);
// Filtros
let searchIntegration = $state($page.url.searchParams.get('integration_number') || '');
// Filtros centralizados
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
let timeout: ReturnType<typeof setTimeout>;
// State for infinite scroll
let allItems = $state<Doda[]>(data.dodas?.items || []);
let currentPage = $state(data.dodas?.page || 1);
let pageSize = $state(50);
let totalItems = $state(data.dodas?.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
// Selection
let selectedId = $state<number | null>(null);
const selectedDoda = $derived(selectedId ? allItems.find((i) => i.id === selectedId) : null);
// Funciones de acción
function handleCreateClick() {
goto('/dashboard/general_catalogs/doda/edit');
}
// Sincronizar con datos del servidor al cargar (primera carga)
$effect(() => {
if (data.dodas) {
allItems = data.dodas.items || [];
currentPage = data.dodas.page || 1;
totalItems = data.dodas.total || 0;
}
});
// Sincronizar filtros con la URL de forma reactiva
$effect(() => {
if (browser) {
const params = new URLSearchParams();
if (filters.integration_number) params.set('integration_number', filters.integration_number);
if (filters.patent) params.set('patent', filters.patent);
if (filters.status) params.set('status', filters.status);
if (filters.operation_type) params.set('operation_type', filters.operation_type);
const queryString = params.toString();
const newUrl = queryString ? `?${queryString}` : window.location.pathname;
if (window.location.search !== (queryString ? `?${queryString}` : '')) {
window.history.replaceState({}, '', newUrl);
}
}
});
// Disparar recarga cuando cambian los filtros (con debounce)
$effect(() => {
// Observamos todos los campos de filtros
const _ = { ...filters };
clearTimeout(timeout);
timeout = setTimeout(() => {
reloadData();
}, 400);
});
// Atajos
useShortcuts(
'Lista DODA',
obtenerAtajosListaDoda({
manejarNuevo: handleCreateClick,
manejarActualizar: handleSuccess
manejarActualizar: reloadData
})
);
function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(() => {
const url = new URL($page.url);
if (searchIntegration) url.searchParams.set('integration_number', searchIntegration);
else url.searchParams.delete('integration_number');
async function loadMore() {
if (loading || !hasMore) return;
url.searchParams.set('page', '1');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
// Limpiar filtros vacíos
const activeFilters = Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== '')
);
const res = await getDodas(currentPage + 1, pageSize, activeFilters, Number(companyId));
if (res.data) {
allItems = [...allItems, ...res.data.items];
currentPage++;
totalItems = res.data.total;
}
} catch (e) {
console.error('Error loading more DODAs:', e);
} finally {
loading = false;
}
}
function handleSuccess() {
const url = new URL($page.url);
goto(url, { invalidateAll: true });
selectedId = null;
async function reloadData() {
if (!browser) return;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const activeFilters = Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== '')
);
const res = await getDodas(1, pageSize, activeFilters, Number(companyId));
if (res.data) {
allItems = res.data.items;
currentPage = 1;
totalItems = res.data.total;
selectedId = null;
}
} catch (e) {
console.error('Error reloading DODAs:', e);
// Evitar mostrar error si es solo carga inicial y falla por falta de login etc
if (allItems.length > 0) toast.error('Error al recargar datos');
} finally {
loading = false;
}
}
function clearFilters() {
filters = {
integration_number: '',
patent: '',
status: '',
operation_type: ''
};
}
function handleCreateClick() {
goto('/dashboard/general_catalogs/doda/edit');
}
function handleEdit() {
if (selectedId) {
goto(`/dashboard/general_catalogs/doda/edit/${selectedId}`);
}
}
async function handleDelete() {
if (!selectedId || !companyStore.activeCompany) return;
if (confirm('¿Estás seguro de eliminar este DODA?')) {
try {
await deleteDoda(selectedId, companyStore.activeCompany.id);
toast.success('DODA eliminado correctamente');
reloadData();
} catch (e) {
toast.error('Error al eliminar DODA');
}
}
}
function handleRowClick(doda: Doda) {
selectedId = doda.id;
selectedId = selectedId === doda.id ? null : doda.id;
}
</script>
<div class="flex flex-col gap-4 p-4">
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">DODA</h1>
<p class="text-muted-foreground">Gestión de Documentos de Operación de Aduana</p>
<h1 class="text-3xl font-bold tracking-tight">DODA</h1>
<p class="text-muted-foreground">Gestiona tus Documentos de Operación Aduanera (DODA)</p>
</div>
<!-- <Button onclick={() => dialogOpen = true}> -->
<Button href="/dashboard/general_catalogs/doda/edit">
<Button onclick={handleCreateClick} class="shadow-sm transition-all hover:translate-y-[-1px]">
<Plus class="mr-2 h-4 w-4" />
Nuevo DODA
</Button>
</div>
<div class="flex gap-4 items-end">
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input
placeholder="Buscar por No. Integración..."
bind:value={searchIntegration}
oninput={handleSearch}
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Filtros Avanzados</Card.Title>
<Card.Description>Refina tu búsqueda mediante múltiples criterios</Card.Description>
</div>
<Button variant="ghost" size="sm" onclick={clearFilters} class="text-muted-foreground">
<RotateCcw class="mr-2 h-4 w-4" />
Limpiar Filtros
</Button>
</div>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="space-y-2">
<Label for="search-integration">No. Integración</Label>
<div class="relative">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search-integration"
placeholder="Buscar integración..."
bind:value={filters.integration_number}
class="pl-9"
/>
</div>
</div>
<div class="space-y-2">
<Label for="search-patent">Patente</Label>
<Input id="search-patent" placeholder="Buscar patente..." bind:value={filters.patent} />
</div>
<div class="space-y-2">
<Label>Estatus</Label>
<Select.Root
type="single"
value={filters.status}
onValueChange={(v) => (filters.status = v)}
>
<Select.Trigger class="w-full">
{filters.status || 'Todos los estatus'}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todos</Select.Item>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="VALIDADO">VALIDADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Operación</Label>
<Select.Root
type="single"
value={filters.operation_type}
onValueChange={(v) => (filters.operation_type = v)}
>
<Select.Trigger class="w-full">
{filters.operation_type === 'I'
? 'Importación'
: filters.operation_type === 'E'
? 'Exportación'
: 'Todas'}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todas</Select.Item>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de DODAs</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
</Button>
</div>
</Card.Header>
<Card.Content class="p-0">
<DataTable
data={allItems}
columns={createColumns(reloadData)}
{loading}
{hasMore}
{loadMore}
{selectedId}
onRowClick={handleRowClick}
/>
</Card.Content>
</Card.Root>
<!-- Footer fijo de acciones (estilo Facturas) -->
<div
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
{#if selectedDoda}
Seleccionado: <span class="font-medium text-foreground"
>{selectedDoda.integration_number || 'S/N'}</span
>
{:else}
Selecciona un registro para ver acciones
{/if}
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedId}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</Button>
<Button variant="destructive" size="sm" onclick={handleDelete} disabled={!selectedId}>
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</Button>
<Separator orientation="vertical" class="mx-1 h-8" />
<Button variant="secondary" size="sm" disabled={!selectedId}>
<Printer class="mr-2 h-4 w-4" />
Imprimir
</Button>
</div>
</div>
</div>
</div>
<div class="rounded-md border">
<DataTable
data={data.dodas?.items || []}
columns={createColumns(handleSuccess)}
pageCount={data.dodas?.pages || 0}
totalItems={data.dodas?.total || 0}
{selectedId}
onRowClick={handleRowClick}
/>
</div>
{#if dialogOpen}
<CreateEditDialog bind:open={dialogOpen} onSuccess={handleSuccess} />
{/if}
</div>
<div class="h-20"></div>
<!-- Espacio buffer para el footer -->
{#if dialogOpen}
<CreateEditDialog bind:open={dialogOpen} onSuccess={reloadData} />
{/if}

View File

@@ -8,6 +8,9 @@
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import {
createDoda,
@@ -15,19 +18,42 @@
getDoda,
type DodaCreate
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import { ArrowLeft, LoaderCircle, Save } from 'lucide-svelte';
import {
ArrowLeft,
Save,
RefreshCw,
FileText,
LayoutGrid,
Printer,
Trash2,
FolderSearch,
ShieldCheck,
LoaderCircle
} from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
import ChildDetailTable from '$lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte';
// Modales de Selección
import BrokerSelectorDialog from '$lib/components/dashboard/export/manifest/modals/broker-selector-dialog.svelte';
import CustomsSectionSelectorDialog from '$lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte';
import TransporterSelectorDialog from '$lib/components/dashboard/export/manifest/modals/transporter-selector-dialog.svelte';
// 1. Identificación reactiva
let id = $derived($page.params.id);
let isEdit = $derived(!!id);
let isEdit = $derived(!!$page.params.id);
let title = $derived(isEdit ? 'Editar DODA' : 'Nuevo DODA');
let loading = $state(false);
let error = $state<string | null>(null);
let activeTab = $state('general');
// Estados de Modales
let showBrokerSelector = $state(false);
let showAduanaSelector = $state(false);
let showSectionSelector = $state(false);
let showTransporterSelector = $state(false);
// Atajos
useShortcuts(
'Formulario DODA',
@@ -38,7 +64,12 @@
})
);
function getEmptyForm(): DodaCreate {
function getEmptyForm(): DodaCreate & {
pedimentos_detail?: any[];
containers?: any[];
american_pedimentos?: any[];
uuid_carta_porte?: string;
} {
return {
integration_number: '',
doda_date: undefined,
@@ -50,7 +81,7 @@
caat: '',
transport_identification: '',
fast_id: '',
operation_type: '',
operation_type: 'I',
selected: false,
user_selected: '',
last_user: '',
@@ -62,24 +93,35 @@
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: '',
status: 'PENDIENTE',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: undefined,
unique_badge_number: ''
customs_clearance: 2, // 2 = DODA, 1 = PITA
unique_badge_number: '',
pedimentos_detail: [],
containers: [],
american_pedimentos: [],
uuid_carta_porte: ''
};
}
let formData = $state<DodaCreate>(getEmptyForm());
let formData = $state(getEmptyForm());
$effect(() => {
if (id) {
loadDoda(Number(id));
} else {
const currentId = $page.params.id;
const companyId = companyStore.activeCompany?.id;
console.log('DEBUG: Effect triggered', { currentId, companyId });
if (currentId && companyId) {
console.log('DEBUG: Calling loadDoda with', currentId);
loadDoda(Number(currentId));
} else if (!currentId) {
console.log('DEBUG: No ID, resetting form');
formData = getEmptyForm();
error = null;
}
@@ -89,7 +131,15 @@
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
console.log('DEBUG: loadDoda executing', { dodaId, companyId });
if (!companyId) {
console.error('DEBUG: No company ID available in loadDoda');
return;
}
const data = await getDoda(dodaId, companyId);
console.log('DEBUG: getDoda response', data);
if (data) {
formData = {
@@ -103,7 +153,7 @@
caat: data.caat || '',
transport_identification: data.transport_identification || '',
fast_id: data.fast_id || '',
operation_type: data.operation_type || '',
operation_type: data.operation_type || 'I',
selected: data.selected || false,
user_selected: data.user_selected || '',
last_user: data.last_user || '',
@@ -115,15 +165,19 @@
serial_number: data.serial_number || '',
electronic_signature: data.electronic_signature || '',
transaction_number: data.transaction_number || '',
status: data.status || '',
status: data.status || 'PENDIENTE',
linq_sat_qr: data.linq_sat_qr || '',
sat_certificate: data.sat_certificate || '',
sat_digital_seal: data.sat_digital_seal || '',
xml_doda_sent_path: data.xml_doda_sent_path || '',
xml_doda_response_path: data.xml_doda_response_path || '',
sat_original_chain: data.sat_original_chain || '',
customs_clearance: data.customs_clearance,
unique_badge_number: data.unique_badge_number || ''
customs_clearance: data.customs_clearance || 2,
unique_badge_number: data.unique_badge_number || '',
pedimentos_detail: data.pedimentos_detail || [],
containers: data.containers || [],
american_pedimentos: data.american_pedimentos || [],
uuid_carta_porte: '' // Simulated field
};
}
} catch (e) {
@@ -139,13 +193,11 @@
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('Selecciona una compañía');
if (!formData.integration_number?.trim())
throw new Error('El número de integración es requerido');
if (!formData.patent?.trim()) throw new Error('El Agente Aduanal (Patente) es requerido');
const payload: DodaCreate = {
...formData,
integration_number: formData.integration_number.trim(),
integration_number: formData.integration_number?.trim() || '',
doda_date: formData.doda_date || undefined,
doda_time: formData.doda_time || undefined,
customs_clearance: formData.customs_clearance || undefined
@@ -164,260 +216,524 @@
loading = false;
}
}
const pedimentosColumns = [
{ header: 'Doda Sysid', key: 'id' },
{ header: 'Línea Pedimento', key: 'pedimento_line' },
{ header: 'Patente Autorización', key: 'authorization_patent' },
{ header: 'Documento', key: 'document' },
{ header: 'Remesa', key: 'shipment' },
{ header: 'Cove', key: 'cove' },
{ header: 'UMC', key: 'umc' },
{ header: 'Importe Efectivo USD', key: 'effective_amount_usd' },
{ header: 'Importe Diferencia USD', key: 'difference_amount_usd' },
{ header: 'DTA NIU', key: 'dta_niu' },
{ header: 'Articulo 7', key: 'article_7', render: (v: any) => (v ? 'Sí' : 'No') }
];
const containersColumns = [
{ header: 'Contenedor', key: 'container_value' },
{ header: 'Percinto', key: 'seals' }
];
const americanPedimentosColumns = [
{ header: 'Tipo', key: 'american_pedimento_type' },
{ header: 'Pedido Americano', key: 'american_pedimento_value' }
];
// Handlers de Selección
function handleBrokerSelect(broker: any) {
formData.responsible = broker.broker_key || '';
formData.patent = broker.license || '';
}
function handleAduanaSelect(section: any) {
formData.dispatch_customs = section.customs_code || '';
}
function handleSectionSelect(section: any) {
formData.customs_sections = section.customs_code || '';
}
function handleTransporterSelect(transporter: any) {
formData.carrier = transporter.name || '';
}
</script>
<div class="w-full mx-auto max-w-6xl py-6 px-4 space-y-6 pb-48">
<div class="flex items-center gap-4">
<Button variant="outline" size="icon" href="/dashboard/general_catalogs/doda">
<ArrowLeft class="h-4 w-4" />
</Button>
<div>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
<div class="space-y-3 p-6 pb-48">
<!-- Header Estilo Facturas -->
<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={() => goto('/dashboard/general_catalogs/doda')}
>
<ArrowLeft size={20} />
</Button>
<h1 class="text-3xl font-bold tracking-tight">{title}</h1>
</div>
<p class="text-muted-foreground">Catálogos Generales / Doda</p>
</div>
</div>
{#if error}
<div
class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium"
>
⚠️ {error}
</div>
{/if}
<Separator />
{#key id}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="min-h-[500px]">
<Tabs.Content value="general" class="space-y-4 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="integration_number"
>No. Integración <span class="text-destructive">*</span></Label
>
<Input
id="integration_number"
bind:value={formData.integration_number}
maxlength={30}
/>
</div>
<div class="grid gap-2">
<Label for="status">Estatus</Label>
<Input id="status" bind:value={formData.status} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="doda_date">Fecha (YYYYMMDD)</Label>
<Input
type="number"
id="doda_date"
bind:value={formData.doda_date}
placeholder="Ej: 20240101"
/>
</div>
<div class="grid gap-2">
<Label for="doda_time">Hora (HHMMSS)</Label>
<Input
type="number"
id="doda_time"
bind:value={formData.doda_time}
placeholder="Ej: 143000"
/>
</div>
<div class="grid gap-2">
<Label for="operation_type">Tipo Operación</Label>
<Input id="operation_type" bind:value={formData.operation_type} maxlength={1} />
</div>
</div>
<div class="grid gap-2">
<Label for="pedimentos">Pedimentos</Label>
<Input id="pedimentos" bind:value={formData.pedimentos} />
</div>
<div class="grid gap-2">
<Label for="pedimento_type">Tipo Pedimento</Label>
<Input id="pedimento_type" bind:value={formData.pedimento_type} />
</div>
</Tabs.Content>
<Tabs.Root bind:value={activeTab} class="w-full">
<!-- Contenido Principal con Campos Superiores -->
<div class="space-y-6">
<!-- Fila Compacta de Datos Principales (Estilo InvoiceTopFields) con líneas blancas entre campos -->
<div class="grid grid-cols-12 items-end gap-0 pb-3">
<div class="col-span-3 space-y-1 border-r border-white/20 pr-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Responsable Agentes
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.responsible}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={14}
placeholder="Clave"
onclick={() => (showBrokerSelector = true)}
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-8 w-8 shrink-0 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showAduanaSelector = true)}
>
Aduana
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.dispatch_customs}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showAduanaSelector = true)}
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showAduanaSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showSectionSelector = true)}
>
Sección
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.customs_sections}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showSectionSelector = true)}
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showSectionSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 pl-3">
<Label class="text-xs leading-none text-muted-foreground">Operación</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v) => (formData.operation_type = v)}
>
<Select.Trigger class="h-8 border-none bg-background/5 text-sm font-medium shadow-none">
<span class="truncate"
>{formData.operation_type === 'E'
? 'E'
: formData.operation_type === 'I'
? 'I'
: '...'}</span
>
</Select.Trigger>
<Select.Content>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<Tabs.Content value="transport" class="space-y-4 pt-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="patent">Patente</Label>
<Input id="patent" bind:value={formData.patent} maxlength={4} />
</div>
<div class="grid gap-2">
<Label for="dispatch_customs">Aduana Despacho</Label>
<Input
id="dispatch_customs"
bind:value={formData.dispatch_customs}
maxlength={3}
/>
</div>
<div class="grid gap-2">
<Label for="customs_sections">Sección Aduanera</Label>
<Input
id="customs_sections"
bind:value={formData.customs_sections}
maxlength={3}
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="caat">CAAT</Label>
<Input id="caat" bind:value={formData.caat} />
</div>
<div class="grid gap-2">
<Label for="carrier">Carrier</Label>
<Input id="carrier" bind:value={formData.carrier} />
</div>
<div class="grid gap-2 md:col-span-2">
<Label for="transport_id">Ident. Transporte</Label>
<Input id="transport_id" bind:value={formData.transport_identification} />
</div>
<div class="grid gap-2">
<Label for="fast_id">FAST ID</Label>
<Input id="fast_id" bind:value={formData.fast_id} />
</div>
</div>
<div class="grid gap-2">
<Label for="shipments">Embarques (Shipments)</Label>
<Input id="shipments" bind:value={formData.shipments} />
</div>
<div class="grid gap-2">
<Label for="customs_clearance">Despacho Aduanero (ID)</Label>
<Input
type="number"
id="customs_clearance"
bind:value={formData.customs_clearance}
/>
</div>
</Tabs.Content>
{#if error}
<div
class="animate-in fade-in slide-in-from-top-2 mb-6 flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-4 text-sm font-semibold text-destructive"
>
<span class="text-lg">⚠️</span>
{error}
</div>
{/if}
<Tabs.Content value="sat" class="space-y-4 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="serial">Número de Serie</Label>
<Input id="serial" bind:value={formData.serial_number} />
</div>
<div class="grid gap-2">
<Label for="transaction">No. Transacción</Label>
<Input id="transaction" bind:value={formData.transaction_number} />
</div>
</div>
<div class="grid gap-2">
<Label for="chain">Cadena Original</Label>
<Textarea id="chain" bind:value={formData.original_chain} class="min-h-[80px]" />
</div>
<div class="grid gap-2">
<Label for="signature">Firma Electrónica</Label>
<Textarea
id="signature"
bind:value={formData.electronic_signature}
class="min-h-[80px]"
/>
</div>
<div class="grid gap-2">
<Label for="seal">Sello Digital SAT</Label>
<Textarea id="seal" bind:value={formData.sat_digital_seal} class="min-h-[80px]" />
</div>
<div class="grid gap-2">
<Label for="sat_original_chain">Cadena Original SAT</Label>
<Textarea
id="sat_original_chain"
bind:value={formData.sat_original_chain}
class="min-h-[80px]"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="xml_sent">Ruta XML Enviado</Label>
<Input id="xml_sent" bind:value={formData.xml_doda_sent_path} />
</div>
<div class="grid gap-2">
<Label for="xml_res">Ruta XML Respuesta</Label>
<Input id="xml_res" bind:value={formData.xml_doda_response_path} />
</div>
</div>
</Tabs.Content>
<Tabs.Content value="general" class="animate-in fade-in space-y-6 duration-300 outline-none">
<!-- Grid de Campos Generales Reorganizado -->
<div class="grid grid-cols-12 items-start gap-8">
<!-- Columna 1 (Izquierda): Stack Vertical Principal -->
<div class="col-span-3 space-y-4">
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showTransporterSelector = true)}
>
Transportista
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.carrier}
placeholder="Transportista"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showTransporterSelector = true)}
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showTransporterSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Identificación</Label
>
<Input
bind:value={formData.transport_identification}
placeholder="Identificación"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>No. de Integración</Label
>
<Input
bind:value={formData.integration_number}
placeholder="Integración"
class="h-9 bg-muted/20 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Transacción</Label
>
<Input
bind:value={formData.transaction_number}
placeholder="Transacción"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Fast ID</Label>
<Input
bind:value={formData.fast_id}
placeholder="Fast ID"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
<Tabs.Content value="other" class="space-y-4 pt-4">
<div class="flex items-center gap-3 p-4 border rounded-lg">
<Switch id="selected" bind:checked={formData.selected} />
<Label for="selected">DODA Seleccionado para operación</Label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="user_sel">Usuario Selección</Label>
<Input id="user_sel" bind:value={formData.user_selected} />
</div>
<div class="grid gap-2">
<Label for="last_user">Último Usuario</Label>
<Input id="last_user" bind:value={formData.last_user} />
</div>
</div>
<div class="grid gap-2">
<Label for="responsible">RFC Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} maxlength={14} />
</div>
<div class="grid gap-2">
<Label for="badge">Número Único de Gafete</Label>
<Input id="badge" bind:value={formData.unique_badge_number} />
</div>
<div class="grid gap-2">
<Label for="qr">LINQ SAT QR</Label>
<Input id="qr" bind:value={formData.linq_sat_qr} />
</div>
<div class="grid gap-2">
<Label for="sat_cert">Certificado SAT</Label>
<Input id="sat_cert" bind:value={formData.sat_certificate} />
</div>
</Tabs.Content>
<!-- Columna 4 (Alineada con Operación): Stack de Estatus/Patente -->
<div class="col-span-3 col-start-8 space-y-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">CAAT</Label>
<Input
bind:value={formData.caat}
placeholder="CAAT"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Patente <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.patent}
maxlength={4}
placeholder="Patente"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showBrokerSelector = true)}
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Estatus</Label>
<Select.Root
type="single"
value={formData.status}
onValueChange={(v) => (formData.status = v)}
>
<Select.Trigger class="h-9 w-full text-sm font-medium shadow-sm">
{formData.status || 'Seleccionar...'}
</Select.Trigger>
<Select.Content>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Fila Horizontal: Despacho y Gafete (A la derecha de Fast ID) -->
<div class="col-span-12 grid grid-cols-12 items-end gap-8">
<div class="col-span-3">
<!-- Espacio vacío para alinear con la primera columna si es necesario -->
</div>
<Tabs.List
class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl"
>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="transport">Aduana</Tabs.Trigger>
<Tabs.Trigger value="sat">SAT</Tabs.Trigger>
<Tabs.Trigger value="other">Otros</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
<!-- Despacho Aduanero (A la derecha de Fast ID en términos lógicos) -->
<div class="col-span-4">
<div
class="flex items-center justify-between rounded-xl border bg-muted/30 p-4 shadow-inner"
>
<Label class="text-xs font-semibold tracking-widest text-muted-foreground uppercase"
>Despacho Aduanero</Label
>
<RadioGroup.Root
value={formData.customs_clearance?.toString()}
onValueChange={(v) => (formData.customs_clearance = parseInt(v))}
class="flex gap-6"
>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="1" id="pita" class="h-4 w-4 border-primary" />
<Label for="pita" class="cursor-pointer text-xs font-medium uppercase"
>PITA</Label
>
</div>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="2" id="doda" class="h-4 w-4 border-primary" />
<Label for="doda" class="cursor-pointer text-xs font-medium uppercase"
>DODA</Label
>
</div>
</RadioGroup.Root>
</div>
</div>
<div
class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50 flex justify-end gap-4 shadow-inner"
>
<div class="max-w-6xl mx-auto flex justify-end gap-4 px-4 w-full">
<!-- Gafete Único (A la derecha de Despacho) -->
<div class="col-span-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Número de gafete único</Label
>
<Input
bind:value={formData.unique_badge_number}
placeholder="Gafete único"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
</div>
</div>
<!-- Tabla Principal -->
<div class="pt-4">
<ChildDetailTable
title="Detalle de Pedimentos"
columns={pedimentosColumns}
data={formData.pedimentos_detail || []}
class="border-border bg-card shadow-sm"
/>
</div>
<!-- Tablas Inferiores -->
<div class="grid grid-cols-1 gap-6 pt-4 lg:grid-cols-2">
<ChildDetailTable
title="Contenedores"
columns={containersColumns}
data={formData.containers || []}
/>
<ChildDetailTable
title="Pedimento Americano"
columns={americanPedimentosColumns}
data={formData.american_pedimentos || []}
/>
</div>
</Tabs.Content>
<Tabs.Content value="sellos" class="animate-in fade-in duration-300 outline-none">
<div class="max-w-4xl space-y-8 py-4">
<div class="space-y-1">
<h2 class="text-xl font-bold tracking-tight">Sellos y Firmas</h2>
<p class="text-xs font-semibold text-muted-foreground uppercase">
Validación electrónica ante el SAT
</p>
</div>
<div class="grid grid-cols-1 gap-8">
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Cadena original</Label
>
<Textarea
bind:value={formData.original_chain}
placeholder="Cadena Original..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número de certificado</Label
>
<Input
bind:value={formData.serial_number}
placeholder="Certificado"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica</Label
>
<Textarea
bind:value={formData.electronic_signature}
placeholder="Firma..."
class="min-h-[100px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-primary uppercase"
>UUID Carta Porte</Label
>
<Input
bind:value={formData.uuid_carta_porte}
placeholder="00000000-0000-0000-0000-000000000000"
class="w-full border-muted/60 font-mono text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número certificado SAT</Label
>
<Input
bind:value={formData.sat_certificate}
placeholder="Certificado SAT"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica SAT (Cadena Original SAT)</Label
>
<Textarea
bind:value={formData.sat_original_chain}
placeholder="Firma SAT..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
</div>
</div>
</Tabs.Content>
</div>
<!-- Footer fijo con Tabs.List y Botones de Acción -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-2">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<LayoutGrid size={16} class="mr-2" />
General
</Tabs.Trigger>
<Tabs.Trigger value="sellos" class="whitespace-nowrap">
<ShieldCheck size={16} class="mr-2" />
Sellos
</Tabs.Trigger>
</Tabs.List>
</div>
<div class="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
href="/dashboard/general_catalogs/doda"
variant="outline"
onclick={() => goto('/dashboard/general_catalogs/doda')}
disabled={loading}
class="rounded px-8 font-medium"
>
Cancelar
</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
<Button
onclick={handleSubmit}
disabled={loading}
class="min-w-[200px] rounded font-bold uppercase shadow-sm"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Actualizar' : 'Guardar'}
{isEdit ? 'Guardar Cambios' : 'Crear DODA'}
{/if}
</Button>
</div>
</div>
</form>
{/key}
</div>
</Tabs.Root>
<!-- Diálogos de Selección de Catálogos -->
<BrokerSelectorDialog bind:open={showBrokerSelector} onSelect={handleBrokerSelect} />
<CustomsSectionSelectorDialog bind:open={showAduanaSelector} onSelect={handleAduanaSelect} />
<CustomsSectionSelectorDialog bind:open={showSectionSelector} onSelect={handleSectionSelect} />
<TransporterSelectorDialog
bind:open={showTransporterSelector}
onSelect={handleTransporterSelect}
/>
</div>