chore: resolve second wave of merge conflicts, integrate new DODA despacho module and stabilize catalogues

This commit is contained in:
2026-04-27 13:43:51 -05:00
63 changed files with 10674 additions and 3800 deletions

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

@@ -3,124 +3,162 @@ import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { renderComponent, renderSnippet } from '$lib/components/ui/data-table';
import { createRawSnippet } from 'svelte';
import DataTableActions from './data-table-actions.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;
}
}
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(
onSuccess?: () => void,
{ canEdit = true, canDelete = true }: { canEdit?: boolean; canDelete?: boolean } = {}
): ColumnDef<Doda>[] {
const loc = getLocale() as 'en' | 'es';
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,
@@ -21,9 +21,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?')) {
@@ -32,6 +29,7 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('Selecciona una compañía');
return;
}
@@ -39,19 +37,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>
@@ -66,7 +60,7 @@
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
{#if canEdit}
<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>
@@ -83,9 +77,3 @@
{/if}
</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,
@@ -290,10 +291,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",
@@ -533,10 +530,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"](),