Merge branch 'fix/carga-csv-headers' into featuere/pedimento-logica-clarion

This commit is contained in:
2026-04-27 11:39:02 -06:00
73 changed files with 10998 additions and 3875 deletions

View File

@@ -552,6 +552,43 @@ async function fetchApiFormDataPost<T = any>(
});
}
/**
* Convierte cuerpos de error (JSON o texto) en un mensaje legible para toasts/UX.
* Evita mostrar JSON crudo p. ej. `{"error":"HTTP_ERROR","message":"..."}`.
*/
function messageFromBlobErrorResponse(text: string, status: number): string {
const raw = (text || '').trim();
if (!raw) {
return status === 404
? 'No se encontró el recurso. Prueba otro rango o vuelve a intentar.'
: `Error ${status} al descargar el archivo.`;
}
try {
const data = JSON.parse(raw) as Record<string, unknown>;
if (typeof data.message === 'string' && data.message.trim()) {
return data.message.trim();
}
const d = data.detail;
if (typeof d === 'string' && d.trim()) {
return d.trim();
}
if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
return String((d[0] as { msg: string }).msg).trim();
}
} catch {
// no es JSON: usar texto plano si es corto y legible
}
if (raw.length < 500 && !raw.startsWith('{')) {
return raw;
}
if (raw.startsWith('{')) {
return status === 404
? 'No se encontró información para exportar. Prueba otras fechas o amplía el rango.'
: `Error ${status} al descargar el archivo.`;
}
return raw;
}
async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<Blob> {
const token = getToken();
const headers: Record<string, string> = {
@@ -568,9 +605,8 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
});
if (!response.ok) {
// Try to extract some useful message for debugging/UI.
const text = await response.text().catch(() => '');
throw new Error(text || `Error ${response.status} descargando archivo`);
throw new Error(messageFromBlobErrorResponse(text, response.status));
}
return await response.blob();
}

View File

@@ -0,0 +1,86 @@
import { api, type ApiResponse } from '$lib/api';
export interface DodaAltaLog {
id: number;
doda_id?: number | null;
variant?: string | null;
responsible?: string | null;
patent?: string | null;
dispatch_customs?: string | null;
operation_type?: string | null;
integration_number?: string | null;
task_id?: string | null;
status?: string | null;
message?: string | null;
result_json?: string | null;
company_id: number;
tenant_id: number;
created_at?: string | null;
updated_at?: string | null;
}
export interface DodaAltaLogListResponse {
items: DodaAltaLog[];
total: number;
page: number;
page_size: number;
}
export interface DodaAltaLogCreateDTO {
doda_id?: number | null;
variant?: string | null;
responsible?: string | null;
patent?: string | null;
dispatch_customs?: string | null;
operation_type?: string | null;
integration_number?: string | null;
task_id?: string | null;
status?: string | null;
message?: string | null;
result_json?: string | null;
}
export interface DodaAltaLogUpdateDTO {
status?: string | null;
message?: string | null;
result_json?: string | null;
}
export const dodaAltaLogApi = {
list(
companyId: number,
params?: {
page?: number;
page_size?: number;
doda_id?: number;
search?: string;
}
): Promise<ApiResponse<DodaAltaLogListResponse>> {
const qs = new URLSearchParams({ company_id: companyId.toString() });
if (params?.page) qs.set('page', params.page.toString());
if (params?.page_size) qs.set('page_size', params.page_size.toString());
if (params?.doda_id) qs.set('doda_id', params.doda_id.toString());
if (params?.search) qs.set('search', params.search);
return api.get<DodaAltaLogListResponse>(`/v1/a76/doda/alta-logs?${qs}`);
},
get(id: number, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
return api.get<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
},
create(dto: DodaAltaLogCreateDTO, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
return api.post<DodaAltaLog>(`/v1/a76/doda/alta-logs?company_id=${companyId}`, dto);
},
update(
id: number,
dto: DodaAltaLogUpdateDTO,
companyId: number
): Promise<ApiResponse<DodaAltaLog>> {
return api.put<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`, dto);
},
delete(id: number, companyId: number): Promise<ApiResponse<void>> {
return api.delete(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
}
};

View File

@@ -187,20 +187,295 @@ 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}/detail?${params.toString()}`);
const response = await api.get<Doda>(`/v1/a76/doda/${id}/detail?${params.toString()}`);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al obtener el DODA');
}
return response.data;
}
export async function createDoda(data: DodaCreate, companyId: number): Promise<Doda> {
const response = await api.post(`/v1/a76/doda/?company_id=${companyId}`, data);
const response = await api.post<Doda>(`/v1/a76/doda/?company_id=${companyId}`, data);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al crear el DODA');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/containers — añade contenedor al DODA existente. */
export async function addDodaContainer(
dodaId: number,
body: DodaContainerCreate,
companyId: number
): Promise<DodaContainer> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaContainer>(
`/v1/a76/doda/${dodaId}/containers?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al agregar el contenedor');
}
return response.data;
}
export async function deleteDodaContainer(
dodaId: number,
containerLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
export async function updateDodaContainer(
dodaId: number,
containerLine: number,
body: DodaContainerCreate,
companyId: number
): Promise<DodaContainer> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.put<DodaContainer>(
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al actualizar el contenedor');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/containers/{containerLine}/seals — añade precinto. */
export async function addDodaSeal(
dodaId: number,
containerLine: number,
sealValue: string,
companyId: number
): Promise<DodaContainerSeal> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaContainerSeal>(
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals?${params.toString()}`,
{ seal_value: sealValue }
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al agregar el precinto');
}
return response.data;
}
export async function deleteDodaSeal(
dodaId: number,
containerLine: number,
sealLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals/${sealLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
/** POST /v1/a76/doda/{dodaId}/pedimentos — añade línea al DODA existente. */
export async function addDodaPedimento(
dodaId: number,
body: DodaPedimentoCreate,
companyId: number
): Promise<DodaPedimento> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaPedimento>(
`/v1/a76/doda/${dodaId}/pedimentos?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al guardar el pedimento');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/american-pedimentos */
export async function addDodaAmericanPedimento(
dodaId: number,
body: DodaAmericanPedimentoCreate,
companyId: number
): Promise<DodaAmericanPedimento> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaAmericanPedimento>(
`/v1/a76/doda/${dodaId}/american-pedimentos?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al guardar el pedimento americano');
}
return response.data;
}
export async function deleteDodaAmericanPedimento(
dodaId: number,
pedimentoLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/american-pedimentos/${pedimentoLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
export async function updateDoda(id: number, data: DodaUpdate, companyId: number): Promise<Doda> {
const response = await api.put(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
const response = await api.put<Doda>(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al actualizar el DODA');
}
return response.data;
}
export async function deleteDoda(id: number, companyId: number): Promise<void> {
await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
const response = await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
}
/**
* GET /v1/a76/doda/{id}/print — PDF (requiere sello digital SAT en backend)
*/
export async function printDoda(dodaId: number, companyId: number): Promise<void> {
const params = new URLSearchParams({ company_id: String(companyId) });
const blob = await api.getBlob(`/v1/a76/doda/${dodaId}/print?${params.toString()}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
a.download = `doda_${dodaId}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export type DodaExportFileFormat = 'csv' | 'xls' | 'txt';
export type DodaExportDateMode = 'raw' | 'formatted';
/**
* GET /v1/a76/doda/export — listado por rango (Fecha DODA YYYYMMDD en BD)
*/
export async function exportDodaList(
companyId: number,
opts: {
dateFrom: string;
dateTo: string;
format: DodaExportFileFormat;
dateMode: DodaExportDateMode;
}
): Promise<void> {
const params = new URLSearchParams({
company_id: String(companyId),
date_from: opts.dateFrom,
date_to: opts.dateTo,
format: opts.format,
date_mode: opts.dateMode
});
const ext = opts.format;
const blob = await api.getBlob(`/v1/a76/doda/export?${params.toString()}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `doda_export_${opts.dateFrom}_${opts.dateTo}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* GET /v1/a76/doda/export/pedimentos/{id} — líneas de pedimento del DODA (TSV/csv/txt).
*/
export async function exportDodaPedimentosDetail(
dodaId: number,
companyId: number,
format: DodaExportFileFormat = 'xls'
): Promise<void> {
const params = new URLSearchParams({
company_id: String(companyId),
format
});
const blob = await api.getBlob(`/v1/a76/doda/export/pedimentos/${dodaId}?${params.toString()}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `doda_pedimentos_${dodaId}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// ── Alta DODA API ─────────────────────────────────────────────────────────── //
export interface DodaAltaResponse {
task_id: string;
status: string;
message: string;
}
export interface DodaAltaStatusResponse {
state?: string;
status?: string;
message?: string;
result?: Record<string, unknown>;
error?: string;
}
export interface DodaElegibilidadReason {
field: string;
message: string;
solution?: string;
}
export interface DodaElegibilidadResponse {
can_alta: boolean;
reasons: DodaElegibilidadReason[];
}
export async function postDodaAlta(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaAltaResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.post<DodaAltaResponse>(`/v1/a76/doda/${dodaId}/alta?${params}`, {});
}
export async function getDodaAltaStatus(
taskId: string
): Promise<ApiResponse<DodaAltaStatusResponse>> {
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/alta-status/${taskId}`);
}
export async function getDodaElegibilidad(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaElegibilidadResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.get<DodaElegibilidadResponse>(
`/v1/a76/doda/${dodaId}/alta/elegibilidad?${params}`
);
}

View File

@@ -7,11 +7,19 @@
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRowClick?: (row: TData) => void;
onRowDoubleClick?: (row: TData) => void;
selectedId?: string | number | null;
idField?: keyof TData;
};
let { data, columns, onRowClick, selectedId, idField }: DataTableProps<TData, TValue> = $props();
let {
data,
columns,
onRowClick,
onRowDoubleClick,
selectedId,
idField
}: DataTableProps<TData, TValue> = $props();
const table = $derived(
createSvelteTable({
@@ -46,6 +54,7 @@
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
onclick={() => onRowClick?.(row.original)}
ondblclick={() => onRowDoubleClick?.(row.original)}
class="cursor-pointer transition-colors hover:bg-muted/50 {selectedId &&
idField &&
row.original[idField] === selectedId

View File

@@ -0,0 +1,119 @@
import type { DodaAltaLog } from '$lib/api/dashboard/a76/doda-alta-log';
import type { ColumnDef } from '@tanstack/table-core';
import { createRawSnippet } from 'svelte';
import { renderSnippet } from '$lib/components/ui/data-table';
function formatDateTime(raw?: string | null): string {
if (!raw) return '-';
try {
return new Date(raw).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return raw;
}
}
const STATUS_CLASSES: Record<string, string> = {
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
failure: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
started: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
};
function statusBadge(status: string | null | undefined) {
const s = (status || '').toLowerCase();
const cls = STATUS_CLASSES[s] || 'bg-gray-100 text-gray-700';
return createRawSnippet(() => ({
render: () =>
`<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}">${status || '-'}</span>`
}));
}
export function createAltaLogColumns(): ColumnDef<DodaAltaLog>[] {
return [
{
accessorKey: 'id',
header: '#',
size: 60,
cell: ({ row }) => row.original.id
},
{
accessorKey: 'doda_id',
header: 'DODA ID',
size: 80,
cell: ({ row }) => row.original.doda_id ?? '-'
},
{
accessorKey: 'integration_number',
header: 'No. Integración',
cell: ({ row }) => row.original.integration_number || '-'
},
{
accessorKey: 'variant',
header: 'Tipo',
size: 70,
cell: ({ row }) => (row.original.variant || '-').toUpperCase()
},
{
accessorKey: 'patent',
header: 'Patente',
size: 80,
cell: ({ row }) => row.original.patent || '-'
},
{
accessorKey: 'dispatch_customs',
header: 'Aduana',
size: 80,
cell: ({ row }) => row.original.dispatch_customs || '-'
},
{
accessorKey: 'operation_type',
header: 'Operación',
size: 90,
cell: ({ row }) => row.original.operation_type || '-'
},
{
accessorKey: 'status',
header: 'Estatus',
size: 110,
cell: ({ row }) => renderSnippet(statusBadge(row.original.status), {})
},
{
accessorKey: 'task_id',
header: 'Task ID',
cell: ({ row }) => {
const id = row.original.task_id || '';
const snippet = createRawSnippet(() => ({
render: () =>
`<span class="font-mono text-xs truncate max-w-[180px] block" title="${id}">${id || '-'}</span>`
}));
return renderSnippet(snippet, {});
}
},
{
accessorKey: 'message',
header: 'Mensaje',
cell: ({ row }) => {
const msg = row.original.message || '';
const snippet = createRawSnippet(() => ({
render: () =>
`<span class="text-xs truncate max-w-[220px] block text-muted-foreground" title="${msg}">${msg || '-'}</span>`
}));
return renderSnippet(snippet, {});
}
},
{
accessorKey: 'created_at',
header: 'Fecha',
size: 130,
cell: ({ row }) => formatDateTime(row.original.created_at)
}
];
}

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { LoaderCircle } from 'lucide-svelte';
import {
dodaAltaLogApi,
type DodaAltaLog,
type DodaAltaLogUpdateDTO
} from '$lib/api/dashboard/a76/doda-alta-log';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: DodaAltaLog | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Detalle de Alta DODA' : 'Nuevo Registro de Alta DODA');
let formData = $state<DodaAltaLogUpdateDTO>({
status: null,
message: null,
result_json: null
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (!open) {
error = null;
loading = false;
return;
}
if (item) {
formData = {
status: item.status ?? null,
message: item.message ?? null,
result_json: item.result_json ?? null
};
} else {
formData = { status: null, message: null, result_json: null };
}
});
async function handleSubmit() {
if (loading || !item) return;
error = null;
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) throw new Error('No hay una compañía seleccionada');
const res = await dodaAltaLogApi.update(item.id, formData, company.id);
if (res.error) throw new Error(res.error);
open = false;
onSuccess?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Error desconocido';
} finally {
loading = false;
}
}
async function handleDelete() {
if (!item || !confirm('¿Está seguro de eliminar este registro de alta DODA?')) return;
const company = companyStore.activeCompany;
if (!company) return;
loading = true;
try {
await dodaAltaLogApi.delete(item.id, company.id);
open = false;
onSuccess?.();
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
{#if item}
<Dialog.Description>
Registro #{item.id} — DODA {item.doda_id ?? '-'}{(item.variant || 'doda').toUpperCase()}
</Dialog.Description>
{/if}
</Dialog.Header>
<div class="grid gap-6 py-4">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Datos de solo lectura del DODA -->
{#if item}
<div class="grid grid-cols-2 gap-4 rounded-lg border bg-muted/30 p-4 text-sm">
<div>
<p class="text-xs text-muted-foreground">No. Integración</p>
<p class="font-medium">{item.integration_number || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Responsable</p>
<p class="font-medium">{item.responsible || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Patente</p>
<p class="font-medium">{item.patent || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Aduana Despacho</p>
<p class="font-medium">{item.dispatch_customs || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Tipo Operación</p>
<p class="font-medium">{item.operation_type || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Task ID</p>
<p class="font-mono text-xs break-all">{item.task_id || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Fecha Alta</p>
<p class="font-medium">
{item.created_at
? new Date(item.created_at).toLocaleString('es-MX')
: '-'}
</p>
</div>
</div>
{/if}
<!-- Campos editables -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground leading-none">Estado</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="status">Estatus</Label>
<Input
id="status"
value={formData.status ?? ''}
oninput={(e) => (formData.status = (e.target as HTMLInputElement).value || null)}
placeholder="pending / success / failed"
disabled={loading}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="message">Mensaje</Label>
<Input
id="message"
value={formData.message ?? ''}
oninput={(e) => (formData.message = (e.target as HTMLInputElement).value || null)}
placeholder="Mensaje del servicio externo"
disabled={loading}
/>
</div>
{#if item?.result_json}
<div class="space-y-2 md:col-span-2">
<Label>Resultado JSON</Label>
<pre class="text-xs rounded-md border bg-muted p-3 overflow-auto max-h-48 whitespace-pre-wrap break-all">{(() => {
try { return JSON.stringify(JSON.parse(item.result_json || '{}'), null, 2); }
catch { return item.result_json || ''; }
})()}</pre>
</div>
{/if}
</div>
</div>
</div>
<Dialog.Footer class="flex justify-between gap-2 pt-2">
<Button
type="button"
variant="destructive"
onclick={handleDelete}
disabled={loading || !item}
>
Eliminar
</Button>
<div class="flex gap-2">
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
{#if isEdit}
<Button type="button" onclick={handleSubmit} disabled={loading}>
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{/if}
Guardar
</Button>
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Select from '$lib/components/ui/select';
import { Loader2, FileSpreadsheet } from 'lucide-svelte';
import { m } from '$lib/i18n/messages';
import { exportDodaList, type DodaExportFileFormat, type DodaExportDateMode } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { toast } from 'svelte-sonner';
let {
open = $bindable(false),
companyId
}: {
open: boolean;
companyId: number | undefined;
} = $props();
function isoFirstDayOfMonth(): string {
const d = new Date();
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
return `${y}-${mo}-01`;
}
function isoToday(): string {
const d = new Date();
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${mo}-${day}`;
}
let dateFrom = $state(isoFirstDayOfMonth());
let dateTo = $state(isoToday());
let fileFormat = $state<DodaExportFileFormat>('xls');
/** Alineado al legado: “Imprimir Fecha Juliana” = fechas/hora en archivo numérico (date_mode=raw). */
let printJulian = $state(false);
let busy = $state(false);
$effect(() => {
if (open) {
dateFrom = isoFirstDayOfMonth();
dateTo = isoToday();
fileFormat = 'xls';
printJulian = false;
}
});
function dateModeValue(): DodaExportDateMode {
return printJulian ? 'raw' : 'formatted';
}
async function download() {
if (!companyId) {
toast.error('Seleccione una compañía.');
return;
}
if (!dateFrom || !dateTo) {
toast.error(m['sidebar.doda_alta.export_excel_invalid_dates']());
return;
}
busy = true;
try {
await exportDodaList(companyId, {
dateFrom,
dateTo,
format: fileFormat,
dateMode: dateModeValue()
});
open = false;
toast.success(m['sidebar.doda_alta.export_excel_success']());
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isEmpty =
/no existen doda|no se encontr[óo] informaci[óo]n para exportar|no doda.*range/i.test(
String(msg)
);
toast.error(
isEmpty ? m['sidebar.doda_alta.export_no_data']() : (msg || m['sidebar.doda_alta.export_excel_error']())
);
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="gap-0 overflow-hidden p-0 sm:max-w-md" showCloseButton={true}>
<Dialog.Header class="sr-only">
<Dialog.Title>{m['sidebar.doda_alta.export_excel_badge']()}{m['sidebar.doda_alta.export_report_heading']()}</Dialog.Title>
</Dialog.Header>
<div
class="bg-slate-900 py-2.5 pr-10 pl-4 text-center text-sm font-medium tracking-wide text-slate-50 dark:bg-slate-950"
>
{m['sidebar.doda_alta.export_excel_badge']()}
</div>
<div class="space-y-5 px-6 pb-1 pt-5">
<h2
class="text-foreground text-center text-sm font-semibold uppercase leading-snug tracking-wide sm:text-base"
>
{m['sidebar.doda_alta.export_report_heading']()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-3">
<div class="space-y-2">
<Label for="doda-exp-from" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_inicio']()}</Label>
<input
id="doda-exp-from"
type="date"
bind:value={dateFrom}
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
/>
</div>
<div class="space-y-2">
<Label for="doda-exp-to" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_final']()}</Label>
<input
id="doda-exp-to"
type="date"
bind:value={dateTo}
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
/>
</div>
</div>
<div class="flex flex-col items-center gap-1">
<Button
size="default"
class="w-full min-w-48"
variant="default"
onclick={download}
disabled={busy || !companyId}
>
{#if busy}
<Loader2 class="mr-2 h-4 w-4 shrink-0 animate-spin" />
{:else}
<FileSpreadsheet class="mr-2 h-4 w-4 shrink-0" />
{/if}
{m['sidebar.doda_alta.export_report_generar']()}
</Button>
</div>
<div class="flex items-start gap-2.5">
<Checkbox id="doda-exp-julian" bind:checked={printJulian} class="mt-0.5" />
<Label for="doda-exp-julian" class="text-muted-foreground text-sm font-normal leading-snug">
{m['sidebar.doda_alta.export_julian_label']()}
</Label>
</div>
<div class="border-t pt-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label for="doda-exp-format" class="text-foreground/80 shrink-0 text-sm"
>{m['sidebar.doda_alta.export_format']()}</Label
>
<Select.Root
type="single"
value={fileFormat}
onValueChange={(v) => {
if (v === 'csv' || v === 'xls' || v === 'txt') fileFormat = v;
}}
>
<Select.Trigger id="doda-exp-format" class="w-full min-w-0 sm:max-w-[12rem]">
.{fileFormat}
</Select.Trigger>
<Select.Content>
<Select.Item value="csv">.csv (coma)</Select.Item>
<Select.Item value="xls">.xls (tabulador, Excel)</Select.Item>
<Select.Item value="txt">.txt (|)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
<Dialog.Footer class="border-t px-6 py-4 sm:justify-end">
<Button type="button" variant="outline" class="min-w-24" onclick={() => (open = false)} disabled={busy}>
{m['sidebar.doda_alta.export_cancel']()}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Progress } from '$lib/components/ui/progress';
import { Loader2, CheckCircle, XCircle } from 'lucide-svelte';
import {
getDodaAltaStatus,
type DodaAltaStatusResponse
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import { m } from '$lib/i18n/messages';
let {
open = $bindable(false),
taskId,
dodaId,
variant = 'doda',
onComplete,
onCancel
}: {
open: boolean;
taskId: string;
dodaId?: number;
variant?: 'doda' | 'pita';
onComplete?: (result: DodaAltaStatusResponse) => void;
onCancel?: () => void;
} = $props();
type TaskState = 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE' | 'STARTED';
let state = $state<TaskState>('PENDING');
let currentStep = $state<string>('Iniciando...');
let progress = $state(0);
let result = $state<DodaAltaStatusResponse | null>(null);
let errorMsg = $state<string | null>(null);
let consecutivePollErrors = $state(0);
let pollHandle: ReturnType<typeof setTimeout> | null = null;
let pollInFlight = false;
let pollingActive = false;
let pollingTaskId: string | null = null;
$effect(() => {
if (open && taskId) {
void startPolling();
} else {
stopPolling();
if (!open) resetState();
}
return () => stopPolling();
});
function resetState() {
state = 'PENDING';
currentStep = 'Iniciando...';
progress = 0;
result = null;
errorMsg = null;
consecutivePollErrors = 0;
}
async function startPolling() {
if (pollingActive && pollingTaskId === taskId) return;
stopPolling();
pollingActive = true;
pollingTaskId = taskId;
await poll();
}
function stopPolling() {
pollingActive = false;
pollingTaskId = null;
if (pollHandle !== null) {
clearTimeout(pollHandle);
pollHandle = null;
}
}
function scheduleNextPoll(delayMs = 2000) {
if (!pollingActive || !taskId) return;
if (pollHandle !== null) clearTimeout(pollHandle);
pollHandle = setTimeout(() => void poll(), delayMs);
}
async function poll() {
if (!taskId || !pollingActive || pollInFlight) return;
pollInFlight = true;
try {
const res = await getDodaAltaStatus(taskId);
if (res.error) {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = res.error || 'No se pudo consultar el estado del alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
return;
}
if (!res.data) {
scheduleNextPoll();
return;
}
const data = res.data;
consecutivePollErrors = 0;
const rawState = (data.state || data.status || 'PENDING').toUpperCase();
state = (rawState === 'FAILED' ? 'FAILURE' : rawState) as TaskState;
currentStep = (data.message as string) || 'Procesando...';
if (typeof (data as Record<string, unknown>)['progress'] === 'number') {
progress = (data as Record<string, unknown>)['progress'] as number;
}
if (rawState === 'SUCCESS') {
result = data;
stopPolling();
onComplete?.(data);
} else if (rawState === 'FAILURE' || rawState === 'FAILED') {
errorMsg = data.error || data.message || 'Error en el alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
} catch {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = 'No se pudo consultar el estado del alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
} finally {
pollInFlight = false;
}
}
function handleCancel() {
stopPolling();
open = false;
onCancel?.();
}
const isTerminal = $derived(state === 'SUCCESS' || state === 'FAILURE');
const variantLabel = $derived(variant === 'pita' ? 'PITA' : 'DODA');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m['sidebar.doda_alta.progress_title']()}</Dialog.Title>
<Dialog.Description>Alta {variantLabel} — Task ID: {taskId}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-2">
{#if state === 'SUCCESS'}
<div class="flex items-center gap-3 text-green-600">
<CheckCircle class="h-6 w-6 shrink-0" />
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_success']()}</p>
</div>
{#if result}
<dl class="text-sm space-y-1">
{#each Object.entries(result) as [key, value]}
{#if key !== 'state' && value && typeof value === 'string'}
<div class="flex gap-2">
<dt class="text-muted-foreground min-w-[130px] capitalize">{key.replace(/_/g, ' ')}:</dt>
<dd class="font-medium break-all">{value}</dd>
</div>
{/if}
{/each}
</dl>
{/if}
{:else if state === 'FAILURE'}
<div class="flex items-start gap-3 text-destructive">
<XCircle class="h-6 w-6 shrink-0 mt-0.5" />
<div class="space-y-1">
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_error']()}</p>
{#if errorMsg}
<p class="text-xs text-muted-foreground">{errorMsg}</p>
{/if}
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-3">
<Loader2 class="h-5 w-5 animate-spin text-primary shrink-0" />
<p class="text-sm text-muted-foreground">{currentStep}</p>
</div>
{#if progress > 0}
<Progress value={progress} max={100} class="h-2" />
<p class="text-xs text-right text-muted-foreground">{progress}%</p>
{/if}
</div>
{/if}
{#if taskId}
<div class="rounded-md border bg-muted/30 px-3 py-2">
<p class="text-xs text-muted-foreground">Task ID:</p>
<p class="text-xs font-mono break-all">{taskId}</p>
</div>
{/if}
</div>
<Dialog.Footer class="flex justify-end">
{#if isTerminal}
<Button onclick={() => { open = false; }}>Cerrar</Button>
{:else}
<Button variant="outline" onclick={handleCancel}>Cancelar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,8 +1,9 @@
<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 { Plus, Pencil, Trash2, Inbox } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { dodaFormT } from '$lib/i18n/doda-form-strings';
interface Column {
header: string;
@@ -12,56 +13,115 @@
let {
title = '',
/** `en` / `es` (viene del padre; evita leer `page` aquí, más seguro con SSR) */
locale: localeProp = 'es',
columns = [],
data = [],
onAdd,
onEdit,
onDelete,
/** Se dispara al elegir una fila (click / Enter). Útil p. ej. para precintos por contenedor. */
onRowSelect,
/** p. ej. `max-h-48` para limitar altura y scroll interno en tablas amplias */
bodyMaxClass = '',
class: className = ''
}: {
title?: string;
locale?: 'en' | 'es';
columns: Column[];
data: any[];
onAdd?: () => void;
onEdit?: (item: any, index: number) => void;
onDelete?: (item: any, index: number) => void;
onRowSelect?: (item: any, index: number) => void;
bodyMaxClass?: string;
class?: string;
} = $props();
const dodaLoc = $derived((localeProp === 'en' ? 'en' : 'es') as 'en' | 'es');
let selectedIndex = $state<number | null>(null);
$effect(() => {
if (data.length === 0) {
selectedIndex = null;
} else if (selectedIndex != null && selectedIndex >= data.length) {
selectedIndex = data.length - 1;
}
});
</script>
<div class={cn('space-y-4 rounded-xl border bg-card p-4 shadow-sm', className)}>
<div class="flex items-center justify-between">
<div
class={cn(
'space-y-0 overflow-hidden rounded-lg border border-border/60 bg-card/80 p-0 shadow-sm',
className
)}
>
<div class="flex items-center border-b border-border/50 bg-muted/25 px-3 py-1.5">
{#if title}
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">{title}</h3>
<h3 class="text-xs font-medium text-muted-foreground">{title}</h3>
{/if}
</div>
<div class="relative overflow-hidden rounded-md border bg-background">
<div
class={cn(
'relative overflow-x-auto border-b border-border/40 bg-background/50',
bodyMaxClass
)}
>
<Table.Root>
<Table.Header class="bg-muted/50">
<Table.Row inTabOrder={false}>
<Table.Header class="bg-muted/40">
<Table.Row inTabOrder={false} class="hover:bg-transparent">
{#each columns as col}
<Table.Head class="h-10 px-4 text-xs font-semibold whitespace-nowrap"
>{col.header}</Table.Head
<Table.Head
class="h-7 px-2 py-1.5 text-left text-[0.65rem] font-medium uppercase leading-tight text-muted-foreground sm:px-3 sm:text-[0.7rem]"
>
{col.header}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if data.length === 0}
<Table.Row inTabOrder={false}>
<Table.Row inTabOrder={false} class="hover:bg-transparent">
<Table.Cell
colspan={columns.length}
class="h-24 text-center text-sm text-muted-foreground"
class="h-16 py-2 text-center"
>
No hay registros.
<div
class="flex flex-col items-center justify-center gap-1 text-muted-foreground"
>
<Inbox class="h-5 w-5 opacity-45" />
<span class="text-xs">{dodaFormT(dodaLoc, 'child_empty')}</span>
</div>
</Table.Cell>
</Table.Row>
{:else}
{#each data as row, i}
<Table.Row inTabOrder={false} class="group transition-colors hover:bg-muted/30">
<Table.Row
inTabOrder={false}
role="button"
tabindex="0"
data-state={selectedIndex === i ? 'selected' : undefined}
class="group transition-colors hover:bg-muted/30 {selectedIndex === i
? 'bg-primary/5 ring-1 ring-inset ring-primary/25'
: ''}"
onclick={() => {
selectedIndex = i;
onRowSelect?.(row, i);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedIndex = i;
onRowSelect?.(row, i);
}
}}
>
{#each columns as col}
<Table.Cell class="px-4 py-2 text-sm whitespace-nowrap">
<Table.Cell
class="max-w-[12rem] truncate px-2 py-1.5 text-xs sm:max-w-[14rem] sm:px-3 sm:text-sm"
>
{#if col.render}
{col.render(row[col.key])}
{:else}
@@ -76,30 +136,38 @@
</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">
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-border/30 bg-muted/20 px-3 py-2 sm:px-4">
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1.5 px-3 text-xs font-medium">
<Plus class="h-3.5 w-3.5" />
Nuevo
{dodaFormT(dodaLoc, 'child_new')}
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => {}}
onclick={() => {
if (selectedIndex == null) return;
const row = data[selectedIndex];
if (row) onEdit?.(row, selectedIndex);
}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
disabled={data.length === 0 || selectedIndex == null}
>
<Pencil class="h-3.5 w-3.5" />
Editar
{dodaFormT(dodaLoc, 'child_edit')}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => {}}
onclick={() => {
if (selectedIndex == null) return;
const row = data[selectedIndex];
if (row) onDelete?.(row, selectedIndex);
}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
disabled={data.length === 0 || selectedIndex == null}
>
<Trash2 class="h-3.5 w-3.5" />
Borrar
{dodaFormT(dodaLoc, 'child_delete')}
</Button>
</div>
</div>

View File

@@ -2,121 +2,157 @@ import type { ColumnDef } from '@tanstack/table-core';
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { renderSnippet } from '$lib/components/ui/data-table';
import { createRawSnippet } from 'svelte';
import { getLocale } from '$lib/paraglide/runtime';
import { dodaFormT, type DodaFormKey } from '$lib/i18n/doda-form-strings';
function formatDate(date?: string | null): string {
if (!date) return '-';
// Supposing created_at is an ISO string or similar
/**
* doda_date se almacena como Integer con formato YYYYMMDD (ej. 20180409).
* Lo convertimos a DD/MM/YYYY para mostrar.
*/
function formatDodaDate(val?: number | string | null): string {
if (!val) return '-';
const s = String(val);
if (s.length === 8) {
const y = s.slice(0, 4);
const m = s.slice(4, 6);
const d = s.slice(6, 8);
return `${d}/${m}/${y}`;
}
// Fallback: ISO string
try {
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
return new Date(s).toLocaleDateString(getLocale() === 'en' ? 'en-US' : 'es-MX', {
day: '2-digit',
month: '2-digit',
day: '2-digit'
year: 'numeric'
});
} catch (e) {
return date;
} catch {
return s;
}
}
export function createColumns(): ColumnDef<Doda>[] {
const STATUS_CLASSES: Record<string, string> = {
GENERADO: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'EN PROCESO':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
VALIDADO: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
PENDIENTE: 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300',
ELIMINADO: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
export function createColumns(loc: 'en' | 'es'): ColumnDef<Doda>[] {
const t = (k: DodaFormKey) => dodaFormT(loc, k);
return [
{
accessorKey: 'id',
header: 'Folio',
header: t('list_col_folio'),
size: 70,
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 });
const n = row.original.id;
const s = createRawSnippet(() => ({
render: () =>
`<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${n}</code>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'created_at',
header: 'Fecha doda',
accessorKey: 'doda_date',
header: t('list_col_doda_date'),
size: 100,
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) });
const d = formatDodaDate(row.original.doda_date);
const s = createRawSnippet(() => ({
render: () => `<span class="text-sm tabular-nums">${d}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'dispatch_customs',
header: 'Desp',
cell: ({ row }) => row.original.dispatch_customs || 'N/A'
header: t('list_col_desp'),
size: 60,
cell: ({ row }) => row.original.dispatch_customs || '-'
},
{
accessorKey: 'patent',
header: 'Patente',
cell: ({ row }) => row.original.patent || 'N/A'
header: t('list_col_patent'),
size: 70,
cell: ({ row }) => row.original.patent || '-'
},
{
accessorKey: 'pedimentos',
header: 'Pedimento(s)',
cell: ({ row }) => row.original.pedimentos || 'N/A'
header: t('list_col_pedimentos'),
cell: ({ row }) => {
const v = row.original.pedimentos || '-';
const s = createRawSnippet(() => ({
render: () =>
`<span class="block truncate max-w-[160px]" title="${v}">${v}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'shipments',
header: 'Remesa(s)',
cell: ({ row }) => row.original.shipments || 'N/A'
header: t('list_col_remesas'),
size: 90,
cell: ({ row }) => row.original.shipments || '-'
},
{
accessorKey: 'integration_number',
header: 'Integracion',
header: t('list_col_integracion'),
size: 110,
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 });
const v = row.original.integration_number;
const s = createRawSnippet(() => ({
render: () =>
v
? `<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${v}</code>`
: `<span class="text-muted-foreground">-</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'transaction_number',
header: 'No transaccion',
cell: ({ row }) => row.original.transaction_number || 'N/A'
header: t('list_col_trans'),
cell: ({ row }) => {
const v = row.original.transaction_number || '-';
const s = createRawSnippet(() => ({
render: () =>
`<span class="font-mono text-xs block truncate max-w-[160px]" title="${v}">${v}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'transport_identification',
header: 'Id transporte',
cell: ({ row }) => row.original.transport_identification || 'N/A'
header: t('list_col_id_transport'),
size: 120,
cell: ({ row }) => row.original.transport_identification || '-'
},
{
accessorKey: 'caat',
header: 'CAAT',
cell: ({ row }) => row.original.caat || 'N/A'
header: t('list_col_caat'),
size: 70,
cell: ({ row }) => row.original.caat || '-'
},
{
accessorKey: 'last_user',
header: 'Usuario',
cell: ({ row }) => row.original.last_user || 'N/A'
header: t('list_col_user'),
size: 90,
cell: ({ row }) => row.original.last_user || '-'
},
{
accessorKey: 'status',
header: 'Estatus',
header: t('list_col_status'),
size: 110,
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 });
const status = (row.original.status || '').toUpperCase();
const cls = STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300';
const s = createRawSnippet(() => ({
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${cls}">${status || '-'}</span>`
}));
return renderSnippet(s, {});
}
}
];

View File

@@ -1,392 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import { companyStore } from '$lib/stores/company.svelte';
import { createDoda, updateDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Doda | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? `Editar DODA ${item?.integration_number || ''}` : 'Nuevo DODA');
// Estado del formulario
let activeTab = $state('general');
let formData = $state({
integration_number: '',
doda_date: undefined as number | undefined,
doda_time: undefined as number | undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: '',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: '',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: undefined as number | undefined,
unique_badge_number: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Atajos
// Cargar datos
$effect(() => {
if (open) {
activeTab = 'general';
if (item) {
formData = {
integration_number: item.integration_number || '',
doda_date: item.doda_date,
doda_time: item.doda_time,
dispatch_customs: item.dispatch_customs || '',
customs_sections: item.customs_sections || '',
patent: item.patent || '',
pedimentos: item.pedimentos || '',
caat: item.caat || '',
transport_identification: item.transport_identification || '',
fast_id: item.fast_id || '',
operation_type: item.operation_type || '',
selected: item.selected || false,
user_selected: item.user_selected || '',
last_user: item.last_user || '',
responsible: item.responsible || '',
carrier: item.carrier || '',
shipments: item.shipments || '',
pedimento_type: item.pedimento_type || '',
original_chain: item.original_chain || '',
serial_number: item.serial_number || '',
electronic_signature: item.electronic_signature || '',
transaction_number: item.transaction_number || '',
status: item.status || '',
linq_sat_qr: item.linq_sat_qr || '',
sat_certificate: item.sat_certificate || '',
sat_digital_seal: item.sat_digital_seal || '',
xml_doda_sent_path: item.xml_doda_sent_path || '',
xml_doda_response_path: item.xml_doda_response_path || '',
sat_original_chain: item.sat_original_chain || '',
customs_clearance: item.customs_clearance,
unique_badge_number: item.unique_badge_number || ''
};
} else {
// Reset
formData = {
integration_number: '',
doda_date: undefined,
doda_time: undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: '',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: '',
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: ''
};
}
error = null;
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = null;
try {
const idToUpdate = item?.id;
if (isEdit && idToUpdate) {
await updateDoda(idToUpdate, formData, companyId);
} else {
await createDoda(formData, companyId);
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
console.error(e);
error = e instanceof Error ? e.message : 'Error al guardar DODA';
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="py-4"
>
{#if error}
<div class="mb-4 rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="transport">Aduana/Transp.</Tabs.Trigger>
<Tabs.Trigger value="sat">SAT / Digital</Tabs.Trigger>
<Tabs.Trigger value="other">Otros</Tabs.Trigger>
</Tabs.List>
<!-- TAB: GENERAL -->
<Tabs.Content value="general" class="space-y-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="integration_number">No. Integración</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} maxlength={30} />
</div>
</div>
<div class="grid 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} />
</div>
<div class="grid gap-2">
<Label for="doda_time">Hora (HHMMSS)</Label>
<Input type="number" id="doda_time" bind:value={formData.doda_time} />
</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} maxlength={80} />
</div>
<div class="grid gap-2">
<Label for="pedimento_type">Tipo Pedimento</Label>
<Input id="pedimento_type" bind:value={formData.pedimento_type} maxlength={30} />
</div>
</Tabs.Content>
<!-- TAB: ADUANA / TRANSPORTE -->
<Tabs.Content value="transport" class="space-y-4 py-4">
<div class="grid 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-2 gap-4">
<div class="grid gap-2">
<Label for="caat">CAAT</Label>
<Input id="caat" bind:value={formData.caat} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="carrier">Transportista (Carrier)</Label>
<Input id="carrier" bind:value={formData.carrier} maxlength={8} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="transport_identification">Ident. Transporte</Label>
<Input
id="transport_identification"
bind:value={formData.transport_identification}
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="fast_id">FAST ID</Label>
<Input id="fast_id" bind:value={formData.fast_id} maxlength={20} />
</div>
</div>
<div class="grid gap-2">
<Label for="shipments">Embarques (Shipments)</Label>
<Input id="shipments" bind:value={formData.shipments} maxlength={80} />
</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>
<!-- TAB: SAT / DIGITAL -->
<Tabs.Content value="sat" class="space-y-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="serial_number">Número de Serie</Label>
<Input id="serial_number" bind:value={formData.serial_number} maxlength={21} />
</div>
<div class="grid gap-2">
<Label for="transaction_number">No. Transacción</Label>
<Input
id="transaction_number"
bind:value={formData.transaction_number}
maxlength={30}
/>
</div>
</div>
<div class="grid gap-2">
<Label for="unique_badge_number">Número Único de Gafete</Label>
<Input
id="unique_badge_number"
bind:value={formData.unique_badge_number}
maxlength={250}
/>
</div>
<div class="grid gap-2">
<Label for="original_chain">Cadena Original</Label>
<Textarea id="original_chain" bind:value={formData.original_chain} class="h-20" />
</div>
<div class="grid gap-2">
<Label for="electronic_signature">Firma Electrónica</Label>
<Textarea
id="electronic_signature"
bind:value={formData.electronic_signature}
class="h-20"
/>
</div>
<div class="grid gap-2">
<Label for="sat_digital_seal">Sello Digital SAT</Label>
<Textarea id="sat_digital_seal" bind:value={formData.sat_digital_seal} class="h-20" />
</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="h-20"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="xml_doda_sent_path">Ruta XML Enviado</Label>
<Input id="xml_doda_sent_path" bind:value={formData.xml_doda_sent_path} />
</div>
<div class="grid gap-2">
<Label for="xml_doda_response_path">Ruta XML Respuesta</Label>
<Input id="xml_doda_response_path" bind:value={formData.xml_doda_response_path} />
</div>
</div>
</Tabs.Content>
<!-- TAB: OTROS -->
<Tabs.Content value="other" class="space-y-4 py-4">
<div class="flex items-center space-x-2">
<Switch id="selected" bind:checked={formData.selected} />
<Label for="selected">Seleccionado</Label>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="user_selected">Usuario Selección</Label>
<Input id="user_selected" bind:value={formData.user_selected} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="last_user">Último Usuario</Label>
<Input id="last_user" bind:value={formData.last_user} maxlength={30} />
</div>
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} maxlength={14} />
</div>
<div class="grid gap-2">
<Label for="linq_sat_qr">LINQ SAT QR</Label>
<Input id="linq_sat_qr" bind:value={formData.linq_sat_qr} maxlength={1000} />
</div>
<div class="grid gap-2">
<Label for="sat_certificate">Certificado SAT</Label>
<Input id="sat_certificate" bind:value={formData.sat_certificate} maxlength={2001} />
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer class="mt-6">
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : 'Guardar'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -5,8 +5,8 @@
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { deleteDoda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
let {
item,
@@ -17,9 +17,6 @@
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Doda | null>(null);
async function handleDelete() {
if (!confirm('¿Está seguro de eliminar este registro DODA?')) {
@@ -28,6 +25,7 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('Selecciona una compañía');
return;
}
@@ -35,19 +33,15 @@
try {
await deleteDoda(item.id, companyId);
toast.success('DODA eliminado correctamente');
if (onSuccess) onSuccess();
} catch (err: any) {
error = err.message || 'Error al eliminar el registro';
console.error('Error deleting doda:', err);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Error al eliminar DODA';
toast.error(msg);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
</script>
<DropdownMenu.Root>
@@ -61,7 +55,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda/edit/${item.id}`)}>
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`)}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
@@ -76,8 +70,3 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog
bind:open={dialogOpen}
item={selectedItem}
onSuccess={onSuccess}
/>

View File

@@ -3,6 +3,7 @@
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 { dodaFormT } from '$lib/i18n/doda-form-strings';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -13,6 +14,8 @@
selectedId?: number | null;
onRowClick?: (row: TData) => void;
onRowDoubleClick?: (row: TData) => void;
/** Solo catálogo DODA: textos i18n de carga / vacío */
locale?: 'en' | 'es';
};
let {
@@ -23,9 +26,12 @@
loadMore,
selectedId = null,
onRowClick,
onRowDoubleClick
onRowDoubleClick,
locale = 'es'
}: DataTableProps<TData, TValue> = $props();
const loc = $derived((locale === 'en' ? 'en' : 'es') as 'en' | 'es');
const table = createSvelteTable({
get data() {
return data;
@@ -108,7 +114,7 @@
{:else}
<Table.Row inTabOrder={false}>
<Table.Cell colspan={columns.length} class="h-24 text-center text-sm text-muted-foreground">
No hay resultados.
{dodaFormT(loc, 'list_no_results')}
</Table.Cell>
</Table.Row>
{/each}
@@ -122,10 +128,10 @@
<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>
<span class="text-sm text-muted-foreground">{dodaFormT(loc, 'list_loading_more')}</span>
</div>
{:else}
<div class="text-sm text-muted-foreground">Desplázate para cargar más</div>
<div class="text-sm text-muted-foreground">{dodaFormT(loc, 'list_scroll_for_more')}</div>
{/if}
</div>
</Table.Cell>

View File

@@ -0,0 +1,12 @@
type WithId = { id: number | string };
export function applyOptimisticDelete<T extends WithId>(
items: T[],
total: number,
deletedId: number | string
): { items: T[]; total: number } {
return {
items: items.filter((item) => String(item.id) !== String(deletedId)),
total: Math.max(0, total - 1)
};
}

View File

@@ -0,0 +1,105 @@
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import type { DodaPedimento, DodaPedimentoCreate } from '$lib/api/dashboard/a76/general_catalogs/doda';
export type PedimentoDetailRow = DodaPedimentoCreate & { id?: number; pedimento_line?: number };
/**
* Misma convención que digitalización: AA-Patente-Pedimento
*/
export function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
export function yyyymmddTodayInt(): number {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return Number(`${y}${m}${day}`);
}
/**
* Sincroniza el campo `shipments` a partir de remesas en detalle (coma-separado).
*/
export function syncShipmentsFromPedimentos(
detail: Array<Pick<DodaPedimento | PedimentoDetailRow, 'shipment'>>
): string {
const parts: string[] = [];
for (const p of detail) {
const s = (p.shipment || '').toString().trim();
if (s && !parts.includes(s)) parts.push(s);
}
return parts.join(', ');
}
/**
* Une etiquetas y números de documento al campo legado `pedimentos`.
*/
export function appendPedimentosString(
current: string | undefined,
segment: string
): string {
const next = (segment || '').trim();
if (!next) return current || '';
const cur = (current || '').trim();
if (!cur) return next;
if (cur.split(/[;,]/).map((s) => s.trim()).includes(next)) return cur;
return `${cur}; ${next}`;
}
export function pedimentoRowFromCatalog(
p: Pedimento,
authorizationPatent: string
): { row: DodaPedimentoCreate; label: string } {
const document =
(p.pedimento_number && String(p.pedimento_number)) ||
buildPedimentoLabel(p) ||
'';
return {
row: {
authorization_patent: (authorizationPatent || p.license || '').trim() || undefined,
document: document
},
label: buildPedimentoLabel(p)
};
}
/** `customs_clearance === 1` = PITA (legacy DespachoAduanero '3'): no aplica catálogo de tipo americano. */
export function isPitaCustomsClearance(customsClearance: number | undefined | null): boolean {
return customsClearance === 1;
}
/** Códigos de error para mapear a i18n (`sidebar.doda_form.*`). */
export type AmericanPedimentoTipoError = 'required' | 'import_range' | 'export_range' | 'op_undefined';
/**
* Valida `american_pedimento_type` frente a `operation_type` (legacy Clarion).
* Importación: tipos 15. Exportación: tipos 68.
*/
export function validateAmericanPedimentoTipo(
operationType: string | undefined,
tipo: string | undefined
): AmericanPedimentoTipoError | null {
const t = (tipo || '').trim();
if (!t) return 'required';
const op = (operationType || '').trim().toUpperCase();
const isImport = op === 'I' || op === '1';
const isExport = op === 'E' || op === '2';
if (isImport) {
if (!['1', '2', '3', '4', '5'].includes(t)) {
return 'import_range';
}
} else if (isExport) {
if (!['6', '7', '8'].includes(t)) {
return 'export_range';
}
} else {
return 'op_undefined';
}
return null;
}

View File

@@ -13,6 +13,7 @@ import {
Hash,
LayoutDashboard,
Package,
PackageCheck,
Settings2,
Shield,
Ship,
@@ -283,10 +284,6 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.customs_warehouses"](),
url: "/dashboard/reference_data/customs_warehouses",
},
{
title: m["sidebar.general_catalogs.doda"](),
url: "/dashboard/general_catalogs/doda",
},
{
title: m["sidebar.general_catalogs.prevalidators"](),
url: "/dashboard/general_catalogs/prevalidators",
@@ -519,10 +516,19 @@ export function getSidebarData(): SidebarData {
],
},
{
title: m["sidebar.digitalizacion.title"](),
url: "/dashboard/digitalizacion",
icon: FolderArchive,
items: [],
title: m["sidebar.despacho.title"](),
url: "#",
icon: PackageCheck,
items: [
{
title: m["sidebar.despacho.digitalizacion"](),
url: "/dashboard/digitalizacion",
},
{
title: m["sidebar.despacho.doda"](),
url: "/dashboard/despacho/doda",
},
],
},
{
title: m["sidebar.reference_data.configuracion"](),

View File

@@ -248,7 +248,8 @@ export const catalogosConfig: CsvUploadItem[] = [
icon: Briefcase,
modelTarget: 'Bom',
templateId: 'boms',
layoutModule: 'layouts_csv/boms'
layoutModule: 'layouts_csv/boms',
disabled: true
},
{
id: 'items',

View File

@@ -1,22 +1,46 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosFormularioDoda = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
}): ShortcutDef[] => [
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];
/**
* Atajos específicos para el formulario completo en pantalla
* (modal de la lista con `?doda_id=`), donde solo existen
* las pestañas General y Sellos.
*/
export const obtenerAtajosFormularioDodaPagina = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
}): ShortcutDef[] => [
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Sellos', action: () => acciones.cambiarPestana('sellos') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];

View File

@@ -0,0 +1,60 @@
/**
* Catálogo duplicado desde `messages/{locale}.json` → `sidebar.doda_form`.
* Los JSON viven bajo `src/` para que Vite los empaquete; importar `../../../messages/*`
* provocaba `GET /messages/*.json?import` → 403 con la config actual del dev server.
*/
import { getLocale } from '$lib/paraglide/runtime';
import esDoda from './doda-form/messages.es.json';
import enDoda from './doda-form/messages.en.json';
import type { AmericanPedimentoTipoError } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
const esBlock = esDoda;
const enBlock = enDoda ?? esBlock;
if (!esBlock) {
throw new Error('doda-form: empty messages.es.json');
}
const blocks = { es: esBlock, en: enBlock! };
export type DodaFormKey = keyof typeof esDoda;
const AMERICAN_TIPO_ERR: Record<AmericanPedimentoTipoError, DodaFormKey> = {
required: 'err_american_tipo_required',
import_range: 'err_american_tipo_import',
export_range: 'err_american_tipo_export',
op_undefined: 'err_american_op_undefined'
};
function interpolate(template: string, params?: Record<string, string | number>) {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
}
/**
* Idioma real de la app (cookie / Paraglie). No usar solo el path: el dashboard suele
* ser `/dashboard/...` con locale en `PARAGLIDE_LOCALE`, no `/en/dashboard/...`.
*/
export function dodaFormLocaleFromRuntime(): 'en' | 'es' {
return getLocale() === 'en' ? 'en' : 'es';
}
/** Si alguna ruta usara prefijo URL `/en/...`, puedes combinar con runtime. */
export function dodaFormLocaleFromPathname(pathname: string): 'en' | 'es' {
return pathname.split('/').filter(Boolean)[0] === 'en' ? 'en' : 'es';
}
export function dodaFormT(
locale: 'en' | 'es',
key: DodaFormKey,
params?: Record<string, string | number>
): string {
const block = blocks[locale] ?? blocks.es;
const raw = block[key] ?? blocks.es[key] ?? String(key);
return interpolate(String(raw), params);
}
export function dodaFormAmericanTipoMessage(
locale: 'en' | 'es',
code: AmericanPedimentoTipoError
): string {
return dodaFormT(locale, AMERICAN_TIPO_ERR[code]);
}

View File

@@ -0,0 +1,195 @@
{
"shortcuts_scope": "DODA form",
"title_new": "New DODA",
"title_edit": "Edit DODA",
"description_catalog": "Catalogs · DODA",
"tab_general": "General",
"tab_seals_sat": "Seals and SAT",
"shortcuts_hint": "Alt+1/2 · Ctrl+S save · Esc cancel",
"btn_cancel": "Cancel",
"btn_save": "Save",
"btn_saving": "Saving...",
"btn_save_changes": "Save changes",
"btn_create_doda": "Create DODA",
"btn_accept": "OK",
"card_broker_customs": "Customs agent and office",
"card_transport": "Transport",
"card_control": "Control and dispatch",
"card_sat_chain": "Original chain and signatures (SAT)",
"label_responsible": "Broker",
"label_patent": "Patent",
"label_dispatch": "Dispatch office",
"label_section_es": "E/S section",
"label_operation_type": "Operation type",
"label_transporter": "Carrier",
"label_transport_id": "Transport ID",
"label_caat": "CAAT",
"label_doda_date": "DODA date",
"label_status": "Status",
"label_dispatch_type": "Dispatch type",
"label_unique_badge": "Unique badge",
"label_integration_num": "Integration No.",
"label_transaction_num": "Transaction No.",
"label_fast_id": "Fast ID",
"label_last_user": "Last user",
"label_original_chain": "Original chain",
"label_serial_cert": "Serial (certificate)",
"label_uuid_cp": "Carta porte UUID",
"label_electronic_sig": "Electronic signature",
"label_sat_cert": "SAT certificate",
"label_sat_chain": "Original SAT chain",
"ph_aga": "AGA key",
"ph_0000": "0000",
"ph_000": "000",
"ph_select": "Select",
"ph_plate": "Plate / vehicle ID",
"ph_dash": "—",
"ph_yyyymmdd": "YYYYMMDD",
"ph_badge_pita": "N/A — PITA",
"ph_badge_num": "Badge no.",
"ph_example_container": "E.g. 53056",
"op_import": "I — Import",
"op_export": "E — Export",
"type_pita": "PITA",
"type_doda": "DODA",
"vu_checking": "Verifying agent VU DODA…",
"vu_incomplete": "VU DODA incomplete: agent needs .cer, .key, and DODA FIEL password.",
"vu_complete": "VU DODA complete for API submission.",
"badge_required_hint": "Required for DODA filing API.",
"pedimentos": "Pedimentos",
"lines": "lines",
"containers": "Containers",
"american_pedimentos": "U.S. pedimentos",
"seals_block_title": "Seals — total in DODA: {n} / 8",
"seals_help": "Select a container. Maximum 8 seals per DODA (SCAII).",
"seals_select_container": "Select a container in the table to view or edit its seals.",
"container_no_id_warning": "Container not saved on server. Enter value, press Save; new containers are sent and reloaded with id for seals.",
"container_line_info": "Container:",
"seal_on_line": "seal(s) on this line",
"line_word": "Line",
"btn_add_seal": "Add seal",
"btn_seal_delete": "Delete",
"seals_empty_line": "No seals on this container.",
"col_line": "Line",
"col_auth_patent": "Auth. patent",
"col_document": "Document",
"col_remesa": "Shipment",
"col_cove": "COVE",
"col_umc": "UMC",
"col_cash_usd": "Cash USD",
"col_diff_usd": "Difference USD",
"col_dta_niu": "DTA NIU",
"col_art7": "Art. 7",
"col_container": "Container",
"col_seals": "Seals",
"col_seal_value": "Seal",
"col_american_type": "Type",
"col_american_ped": "U.S. pedimento",
"col_pedimento_only": "U.S. pedimento",
"yes": "Yes",
"no": "No",
"child_empty": "No rows. “New” to add.",
"child_new": "New",
"child_edit": "Edit",
"child_delete": "Delete",
"modal_container_new": "New container",
"modal_container_edit": "Edit container",
"modal_container_desc": "Enter the container value for the DODA declaration.",
"label_container_value": "Container value",
"modal_seals_in_container": "Seals in container",
"seal_modal_title": "Containers > Seal",
"seal_modal_desc": "Enter the seal value for the selected container.",
"label_seal": "Seal",
"ph_seal": "Seal value",
"american_modal_title": "U.S. pedimento",
"american_modal_desc": "Enter type and value of the U.S. pedimento.",
"label_american_type_short": "U.S. type",
"label_american_value": "U.S. pedimento",
"ph_american_value": "U.S. pedimento value",
"line_label": "Line:",
"select_type": "Select type",
"american_cat_6": "AMERICAN PEDIMENTO",
"american_cat_7": "SELF-DECLARATION",
"american_cat_8": "NOT PRESENT",
"err_american_tipo_required": "U.S. pedimento type is required.",
"err_american_tipo_import": "U.S. pedimento type is not valid for import (must be 1, 2, 3, 4, or 5).",
"err_american_tipo_export": "U.S. pedimento type is not valid for export (must be 6, 7, or 8).",
"err_american_op_undefined": "Set operation type (I/E) before validating the U.S. pedimento.",
"err_company": "Select a company",
"err_responsible": "Broker is required",
"err_patent": "Patent is required",
"err_transport": "Transport ID is required. Select a vehicle.",
"err_badge": "Unique badge number is required for DODA filing.",
"err_vu_wait": "Wait for agent VU DODA check to finish, then try again.",
"err_vu_config": "The customs agent does not have full VU DODA config (.cer, .key, DODA FIEL password).",
"err_min_containers": "Add at least one container for API submission.",
"err_american_new_lines": "Enter the U.S. pedimento value for each new line.",
"err_save": "Error saving",
"toast_saved": "Changes saved successfully.",
"toast_created": "DODA created successfully.",
"load_error": "Could not load DODA",
"warn_vu_incomplete": "This DODAs agent does not have full VU DODA (.cer, .key, DODA FIEL password).",
"warn_vu_fetch": "Could not validate the agents VU settings.",
"warn_broker_select": "Selected agent has incomplete VU DODA. Configure in Customs agents before generating.",
"seal_save_first": "Save the DODA before managing seals.",
"seal_pick_container": "Select a container in the table.",
"seal_not_persisted": "This container is not on the server yet. Save the DODA and reload.",
"seal_empty": "Seal cannot be empty.",
"seal_max": "DODA already has the maximum 8 seals.",
"seal_add_err": "Error adding seal",
"seal_delete_err": "Error removing seal",
"pedimento_remove_blocked": "Cannot remove pedimentos already saved on the server here.",
"container_delete_err": "Error deleting container",
"american_delete_err": "Error deleting U.S. pedimento",
"container_update_err": "Error updating container",
"american_cannot_edit_persisted": "To change saved U.S. pedimentos, remove and add again.",
"err_american_value": "Enter the U.S. pedimento value.",
"err_american_type_or_value": "Enter type and/or U.S. pedimento value.",
"err_containers_max": "A DODA can have at most 4 containers.",
"err_container_empty": "Container value cannot be empty.",
"err_container_not_found": "Container to edit not found.",
"pedimento_selector_title": "Containers > Seal",
"list_page_subtitle": "Manage your Customs Operation Documents (DODA)",
"list_btn_new": "New DODA",
"list_card_title": "DODA list",
"list_ph_folio": "Folio",
"list_ph_patent": "Patent",
"list_filter_status_ph": "Status",
"list_filter_status_all": "All",
"list_filter_op_import": "Import",
"list_filter_op_export": "Export",
"list_filter_op": "Operation",
"list_filter_op_all": "All",
"list_btn_clear": "Clear",
"list_showing": "Showing {a} of {b} records",
"list_active_filters": "Active filters: {n}",
"list_btn_edit": "Edit",
"list_btn_print": "Print",
"list_toast_reload_error": "Error reloading data",
"list_elig_error_prefix": "Error checking eligibility: ",
"list_elig_not_meet": "This DODA does not meet the filing requirements.",
"list_alta_error_prefix": "Error sending DODA filing: ",
"list_print_error": "Error generating DODA PDF",
"list_alta_complete": "DODA filing completed successfully",
"list_shortcuts_scope": "DODA list",
"list_col_folio": "Folio",
"list_col_doda_date": "DODA date",
"list_col_desp": "Cstm.",
"list_col_patent": "Patent",
"list_col_pedimentos": "Pedimento(s)",
"list_col_remesas": "Shipment(s)",
"list_col_integracion": "Integration",
"list_col_trans": "Trans. no.",
"list_col_id_transport": "Transport ID",
"list_col_caat": "CAAT",
"list_col_user": "User",
"list_col_status": "Status",
"list_loading_more": "Loading more...",
"list_scroll_for_more": "Scroll to load more",
"list_confirm_delete": "Are you sure you want to delete this DODA record?",
"list_toast_delete_ok": "DODA deleted successfully",
"list_toast_delete_err": "Error deleting DODA",
"list_filter_i": "I — Import",
"list_filter_e": "E — Export",
"list_no_results": "No results."
}

View File

@@ -0,0 +1,195 @@
{
"shortcuts_scope": "Formulario DODA",
"title_new": "Nuevo DODA",
"title_edit": "Editar DODA",
"description_catalog": "Catálogos · DODA",
"tab_general": "General",
"tab_seals_sat": "Sellos y SAT",
"shortcuts_hint": "Alt+1/2 · Ctrl+S guardar · Esc cancelar",
"btn_cancel": "Cancelar",
"btn_save": "Guardar",
"btn_saving": "Guardando...",
"btn_save_changes": "Guardar cambios",
"btn_create_doda": "Crear DODA",
"btn_accept": "Aceptar",
"card_broker_customs": "Agente aduanal y aduana",
"card_transport": "Transporte",
"card_control": "Control y despacho",
"card_sat_chain": "Cadena original y firmas (SAT)",
"label_responsible": "Responsable",
"label_patent": "Patente",
"label_dispatch": "Aduana despacho",
"label_section_es": "Aduana sección E/S",
"label_operation_type": "Tipo operación",
"label_transporter": "Transportista",
"label_transport_id": "ID transporte",
"label_caat": "CAAT",
"label_doda_date": "Fecha DODA",
"label_status": "Estatus",
"label_dispatch_type": "Tipo despacho",
"label_unique_badge": "Gafete único",
"label_integration_num": "Núm. integración",
"label_transaction_num": "Núm. transacción",
"label_fast_id": "Fast ID",
"label_last_user": "Último usuario",
"label_original_chain": "Cadena original",
"label_serial_cert": "Núm. serie (certificado)",
"label_uuid_cp": "UUID carta porte",
"label_electronic_sig": "Firma electrónica",
"label_sat_cert": "Certificado SAT",
"label_sat_chain": "Cadena original SAT",
"ph_aga": "Clave AGA",
"ph_0000": "0000",
"ph_000": "000",
"ph_select": "Seleccionar",
"ph_plate": "Placa / ID vehículo",
"ph_dash": "—",
"ph_yyyymmdd": "AAAAMMDD",
"ph_badge_pita": "N/A — PITA",
"ph_badge_num": "Núm. gafete",
"ph_example_container": "Ej. 53056",
"op_import": "I — Importación",
"op_export": "E — Exportación",
"type_pita": "PITA",
"type_doda": "DODA",
"vu_checking": "Verificando VU DODA del agente…",
"vu_incomplete": "VU DODA incompleta: se requiere .cer, .key y clave FIEL DODA del agente.",
"vu_complete": "VU DODA completa para envío a API.",
"badge_required_hint": "Requerido para alta DODA en API.",
"pedimentos": "Pedimentos",
"lines": "líneas",
"containers": "Contenedores",
"american_pedimentos": "Pedimentos americanos",
"seals_block_title": "Precintos (candados) — total en el DODA: {n} / 8",
"seals_help": "Selecciona un contenedor en la tabla. Máximo 8 precintos en todo el DODA (regla SCAII).",
"seals_select_container": "Selecciona un contenedor en la tabla de contenedores para ver o editar sus precintos.",
"container_no_id_warning": "Contenedor sin id en el servidor. Completa el valor, pulsa Guardar (arriba); al guardar se envían contenedores nuevos y se recargan con id para precintos.",
"container_line_info": "Contenedor:",
"seal_on_line": "precinto(s) en esta línea",
"line_word": "Línea",
"btn_add_seal": "Agregar precinto",
"btn_seal_delete": "Eliminar",
"seals_empty_line": "Sin precintos en este contenedor.",
"col_line": "Línea",
"col_auth_patent": "Patente auth.",
"col_document": "Documento",
"col_remesa": "Remesa",
"col_cove": "COVE",
"col_umc": "UMC",
"col_cash_usd": "Efectivo USD",
"col_diff_usd": "Diferencia USD",
"col_dta_niu": "DTA NIU",
"col_art7": "Art. 7",
"col_container": "Contenedor",
"col_seals": "Precintos",
"col_seal_value": "Precinto",
"col_american_type": "Tipo",
"col_american_ped": "Pedimento americano",
"col_pedimento_only": "Pedimento americano",
"yes": "Sí",
"no": "No",
"child_empty": "Sin filas. «Nuevo» para añadir.",
"child_new": "Nuevo",
"child_edit": "Editar",
"child_delete": "Borrar",
"modal_container_new": "Nuevo contenedor",
"modal_container_edit": "Editar contenedor",
"modal_container_desc": "Captura el valor del contenedor para la declaración DODA.",
"label_container_value": "Valor contenedor",
"modal_seals_in_container": "Precintos del contenedor",
"seal_modal_title": "Contenedores > Precinto",
"seal_modal_desc": "Captura el valor del precinto para el contenedor seleccionado.",
"label_seal": "Precinto",
"ph_seal": "Valor del precinto",
"american_modal_title": "Pedimento Americano",
"american_modal_desc": "Captura el tipo y valor del pedimento americano.",
"label_american_type_short": "Tipo Ped. Americano",
"label_american_value": "Pedimento Americano",
"ph_american_value": "Valor pedimento americano",
"line_label": "Línea:",
"select_type": "Selecciona tipo",
"american_cat_6": "PEDIMENTO AMERICANO",
"american_cat_7": "AUTODECLARACION",
"american_cat_8": "NO PRESENTA",
"err_american_tipo_required": "El tipo de pedimento americano es obligatorio.",
"err_american_tipo_import": "El tipo de pedimento americano no es correcto para importación (debe ser 1, 2, 3, 4 o 5).",
"err_american_tipo_export": "El tipo de pedimento americano no es correcto para exportación (debe ser 6, 7 u 8).",
"err_american_op_undefined": "Define el tipo de operación (I/E) antes de validar el pedimento americano.",
"err_company": "Selecciona una compañía",
"err_responsible": "El Responsable es requerido",
"err_patent": "El Agente Aduanal (Patente) es requerido",
"err_transport": "La Identificación de Transporte es requerida. Selecciona un vehículo.",
"err_badge": "El Número de Gafete Único es requerido para Alta DODA.",
"err_vu_wait": "Espera a que termine la verificación VU DODA del agente e intenta de nuevo.",
"err_vu_config": "El agente aduanal no tiene configuración VU DODA completa (.cer, .key y clave FIEL DODA).",
"err_min_containers": "Agrega al menos un contenedor con valor para el envío a API.",
"err_american_new_lines": "Indique el valor del pedimento americano en cada línea nueva.",
"err_save": "Error al guardar",
"toast_saved": "Cambios guardados correctamente.",
"toast_created": "DODA creado correctamente.",
"load_error": "No se pudo cargar la información del DODA",
"warn_vu_incomplete": "El agente aduanal de este DODA no tiene VU DODA completa (.cer, .key y clave FIEL DODA).",
"warn_vu_fetch": "No se pudo validar la configuración VU del agente aduanal.",
"warn_broker_select": "El agente seleccionado no tiene VU DODA completa (.cer, .key y clave FIEL DODA). Configúralo en Agentes Aduanales antes de generar.",
"seal_save_first": "Guarda el DODA antes de gestionar precintos.",
"seal_pick_container": "Selecciona un contenedor en la tabla.",
"seal_not_persisted": "Este contenedor aún no está guardado en el servidor. Guarda el DODA (Guardar) y vuelve a abrir o recarga.",
"seal_empty": "El precinto no puede estar vacío.",
"seal_max": "El DODA ya tiene el máximo de 8 precintos.",
"seal_add_err": "Error al agregar el precinto",
"seal_delete_err": "Error al eliminar el precinto",
"pedimento_remove_blocked": "Los pedimentos guardados en servidor no se pueden quitar aquí.",
"container_delete_err": "Error al eliminar el contenedor",
"american_delete_err": "Error al eliminar el pedimento americano",
"container_update_err": "Error al actualizar el contenedor",
"american_cannot_edit_persisted": "Para editar pedimentos americanos guardados, elimínalo y créalo nuevamente.",
"err_american_value": "Indique el valor del pedimento americano.",
"err_american_type_or_value": "Capture tipo o valor del pedimento americano.",
"err_containers_max": "El DODA solo puede tener máximo 4 contenedores.",
"err_container_empty": "El valor del contenedor no puede estar vacío.",
"err_container_not_found": "No se encontró el contenedor a editar.",
"pedimento_selector_title": "Contenedores &gt; Precinto",
"list_page_subtitle": "Gestiona tus Documentos de Operación Aduanera (DODA)",
"list_btn_new": "Nuevo DODA",
"list_card_title": "Listado de DODA",
"list_ph_folio": "Folio",
"list_ph_patent": "Patente",
"list_filter_status_ph": "Estatus",
"list_filter_status_all": "Todos",
"list_filter_op_import": "Importación",
"list_filter_op_export": "Exportación",
"list_filter_op": "Operación",
"list_filter_op_all": "Todas",
"list_btn_clear": "Limpiar",
"list_showing": "Mostrando {a} de {b} registros",
"list_active_filters": "Filtros activos: {n}",
"list_btn_edit": "Editar",
"list_btn_print": "Imprimir",
"list_toast_reload_error": "Error al recargar datos",
"list_elig_error_prefix": "Error al verificar elegibilidad: ",
"list_elig_not_meet": "El DODA no cumple con los requisitos de alta.",
"list_alta_error_prefix": "Error al enviar alta DODA: ",
"list_print_error": "Error al generar el PDF del DODA",
"list_alta_complete": "Alta DODA completada correctamente",
"list_shortcuts_scope": "Lista DODA",
"list_col_folio": "Folio",
"list_col_doda_date": "Fecha DODA",
"list_col_desp": "Desp.",
"list_col_patent": "Patente",
"list_col_pedimentos": "Pedimento(s)",
"list_col_remesas": "Remesa(s)",
"list_col_integracion": "Integración",
"list_col_trans": "Núm. Transacción",
"list_col_id_transport": "Id. Transporte",
"list_col_caat": "CAAT",
"list_col_user": "Usuario",
"list_col_status": "Estatus",
"list_loading_more": "Cargando más...",
"list_scroll_for_more": "Desplázate para cargar más",
"list_confirm_delete": "¿Está seguro de eliminar este registro DODA?",
"list_toast_delete_ok": "DODA eliminado correctamente",
"list_toast_delete_err": "Error al eliminar DODA",
"list_filter_i": "I - Importación",
"list_filter_e": "E - Exportación",
"list_no_results": "No hay resultados."
}