feature/digitalizacion-api

This commit is contained in:
2026-04-20 15:13:48 -06:00
parent 32f0dcc35f
commit 1cd81ebc03
29 changed files with 3275 additions and 1004 deletions

View File

@@ -15,6 +15,10 @@ export interface ExpedienteArchivo {
task_id?: string | null;
external_task_id?: string | null;
acuse_pdf_path?: string | null;
envio_xml_path?: string | null;
respuesta_xml_path?: string | null;
consulta_envio_xml_path?: string | null;
consulta_respuesta_xml_path?: string | null;
company_id: number;
tenant_id: number;
}
@@ -39,10 +43,10 @@ export interface ExpedienteArchivoCreateDTO {
}
export interface DigitalizarRequest {
rfc_consulta: string;
clave_documento: string;
nombre_archivo: string;
archivo_base64: string;
rfc_consulta?: string | null;
clave_documento?: string | null;
nombre_archivo?: string | null;
archivo_base64?: string | null;
}
export interface DigitalizarResponse {
@@ -51,6 +55,13 @@ export interface DigitalizarResponse {
status: string;
}
export interface ExpedienteArchivoUploadResponse {
message: string;
record_id: number;
path: string;
nombre_archivo?: string | null;
}
export interface DigitalizacionResult {
status?: string | null;
message?: string | null;
@@ -76,6 +87,7 @@ export interface DigitalizacionErrorDetail {
export interface DigitalizacionTaskDetailResponse {
task_id: string;
external_task_id?: string | null;
state: string;
status?: string | null;
current_step?: string | null;
@@ -132,6 +144,20 @@ class ExpedienteArchivosApi {
return api.delete<void>(`${this.baseUrl}/${id}?${q}`);
}
async uploadFile(
id: number,
file: File,
companyId: string | number
): Promise<ApiResponse<ExpedienteArchivoUploadResponse>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const formData = new FormData();
formData.append('file', file);
return api.request<ExpedienteArchivoUploadResponse>(`${this.baseUrl}/${id}/upload?${q}`, {
method: 'POST',
body: formData
});
}
async digitalizar(
id: number,
body: DigitalizarRequest,
@@ -146,6 +172,41 @@ class ExpedienteArchivosApi {
`${this.baseUrl}/status-digitalizacion-task/${taskId}`
);
}
async downloadArtifact(
id: number,
artifactType: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
companyId: string | number,
filename?: string
): Promise<void> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const endpoint = `${this.baseUrl}/${id}/artifacts/${artifactType}?${q}`;
const blob = await api.getBlob(endpoint);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `artifact_${id}`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async downloadAllArtifactsZip(id: number, companyId: string | number, baseName?: string): Promise<void> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const endpoint = `${this.baseUrl}/${id}/artifacts-zip?${q}`;
const blob = await api.getBlob(endpoint);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expediente_${baseName || id}.zip`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
export const expedienteArchivosApi = new ExpedienteArchivosApi();

View File

@@ -7,70 +7,52 @@ export interface DocumentTypeDigitization {
active: boolean;
}
export interface DocumentTypeDigitizationCreate {
code: string;
description: string;
active?: boolean;
}
export interface DocumentTypeDigitizationUpdate {
code?: string;
description?: string;
active?: boolean;
export interface DocumentTypeDigitizationListResponse {
items: DocumentTypeDigitization[];
total: number;
page: number;
page_size: number;
}
const BASE_URL = '/v1/a76/document-types-digitization';
/**
* API para Tipos de Documentos de Digitalización
* API de solo lectura para Tipos de Documentos de Digitalización
*/
export const documentTypesDigitizationApi = {
/**
* Obtener todos los tipos de documentos para digitalización
*/
getAll: (activeOnly: boolean = true) => {
// CORRECTO: Al tener BASE_URL con slash, queda "...digitization/?active..."
const url = `${BASE_URL}?active_only=${activeOnly}`;
return api.get<DocumentTypeDigitization[]>(url);
list: (
page = 1,
pageSize = 50,
companyId: number,
search?: string,
activeOnly = false
) => {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString()
});
if (search) {
params.append('search', search);
}
if (activeOnly) {
params.append('active_only', 'true');
}
return api.get<DocumentTypeDigitizationListResponse>(`${BASE_URL}/?${params.toString()}`);
},
/**
* Obtener un tipo de documento por ID
*/
getById: (id: number) => {
// CORREGIDO: Añadido slash después del ID
return api.get<DocumentTypeDigitization>(`${BASE_URL}${id}/`);
getAll: (companyId: number, activeOnly = true, search?: string) => {
return documentTypesDigitizationApi.list(1, 2000, companyId, search, activeOnly);
},
/**
* Obtener un tipo de documento por código
*/
getByCode: (code: string) => {
// CORREGIDO: Añadido slash después del código
return api.get<DocumentTypeDigitization>(`${BASE_URL}by-code/${code}/`);
getById: (id: number, companyId: number) => {
return api.get<DocumentTypeDigitization>(`${BASE_URL}/${id}/?company_id=${companyId}`);
},
/**
* Crear un nuevo tipo de documento
*/
create: (data: DocumentTypeDigitizationCreate) => {
// CORRECTO: Usa la BASE_URL que ya termina en /
return api.post<DocumentTypeDigitization>(BASE_URL, data);
},
/**
* Actualizar un tipo de documento existente
*/
update: (id: number, data: DocumentTypeDigitizationUpdate) => {
// CORREGIDO: Añadido slash después del ID
return api.put<DocumentTypeDigitization>(`${BASE_URL}${id}/`, data);
},
/**
* Eliminar (soft delete) un tipo de documento
*/
delete: (id: number) => {
// CORREGIDO: Añadido slash después del ID
return api.delete(`${BASE_URL}${id}/`);
getByCode: (code: string, companyId: number) => {
return api.get<DocumentTypeDigitization>(`${BASE_URL}/by-code/${code}/?company_id=${companyId}`);
}
};

View File

@@ -1,4 +1,4 @@
<script lang="ts" generics="TData, TValue">
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
@@ -11,6 +11,9 @@
hasMore: boolean;
loadMore: () => void;
emptyMessage?: string;
selectedIds?: number[];
onSelectedIdsChange?: (ids: number[]) => void;
onRowClick?: (row: TData) => void;
};
let {
@@ -19,7 +22,10 @@
loading,
hasMore,
loadMore,
emptyMessage = 'No hay resultados.'
emptyMessage = 'No hay resultados.',
selectedIds = [],
onSelectedIdsChange,
onRowClick,
}: InfiniteDataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -27,7 +33,38 @@
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(),
state: {
get rowSelection() {
const selection: Record<string, boolean> = {};
selectedIds.forEach((id) => {
selection[id.toString()] = true;
});
return selection;
}
},
onStateChange: (updater: any) => {
if (!onSelectedIdsChange) return;
const currentState = table.getState();
const nextState = typeof updater === 'function' ? updater(currentState) : updater;
const rowSelection = nextState?.rowSelection;
if (!rowSelection) {
onSelectedIdsChange([]);
return;
}
const nextSelectedIds = Object.entries(rowSelection)
.filter(([, selected]) => Boolean(selected))
.map(([id]) => Number(id))
.filter((id) => Number.isFinite(id));
onSelectedIdsChange(nextSelectedIds);
},
enableRowSelection: true,
enableMultiRowSelection: true
});
let scrollContainer = $state<HTMLDivElement>();
@@ -117,6 +154,7 @@
<Table.Head
class={[
'catalog-table-head-cell',
colId === 'select' && 'catalog-table-sticky-left z-40 min-w-[2.75rem]',
colId === lastHeaderColId &&
'catalog-table-sticky-right z-30'
]
@@ -141,16 +179,26 @@
{@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id}
<Table.Row
inTabOrder={false}
data-state={row.getIsSelected() && 'selected'}
class="catalog-table-row"
data-state={row.getIsSelected() ? 'selected' : undefined}
class={[
row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row',
onRowClick && 'cursor-pointer'
]
.filter(Boolean)
.join(' ')}
onclick={() => onRowClick?.(row.original)}
>
{#each visibleCells as cell (cell.id)}
{@const colId = cell.column.id}
<Table.Cell
class={[
'whitespace-nowrap',
colId === 'select' && 'catalog-table-sticky-left z-30 min-w-[2.75rem]',
colId === lastCellColId &&
'catalog-table-sticky-right z-10'
'catalog-table-sticky-right z-10',
row.getIsSelected()
? 'catalog-table-sticky-row-selected'
: 'catalog-table-sticky-row-hover'
]
.filter(Boolean)
.join(' ')}

View File

@@ -1,17 +1,28 @@
import type { ExpedienteArchivo } from '$lib/api/dashboard/a76/expediente-archivos';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import { createRawSnippet } from 'svelte';
import { renderComponent, renderSnippet } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
import EDocumentCell from './e-document-cell.svelte';
function formatDate(dateStr?: string | null): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return dateStr;
const raw = String(dateStr);
const ymd = raw.includes('T') ? raw.split('T')[0] : raw;
const parts = ymd.split('-').map(Number);
if (parts.length === 3 && parts.every((part) => Number.isFinite(part))) {
const [year, month, day] = parts;
return new Date(year, month - 1, day).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
return new Date(raw).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
export function createColumns(
@@ -20,6 +31,72 @@ export function createColumns(
onAcuse?: (item: ExpedienteArchivo) => void
): ColumnDef<ExpedienteArchivo>[] {
return [
{
id: 'select',
header: ({ table }) => {
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
const selectAllSnippet = createRawSnippet<[
{ checked: boolean; indeterminate: boolean; onchange: (event: Event) => void }
]>((getProps) => {
const { checked, indeterminate, onchange } = getProps();
return {
render: () => `<div class="w-4">
<input
type="checkbox"
tabindex="-1"
class="h-4 w-4 cursor-pointer"
${checked ? 'checked' : ''}
${indeterminate ? 'indeterminate="true"' : ''}
/>
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement | null;
if (!input) return;
input.indeterminate = indeterminate;
input.addEventListener('change', onchange);
}
};
});
return renderSnippet(selectAllSnippet, {
checked: isAllSelected,
indeterminate: isSomeSelected && !isAllSelected,
onchange: (event: Event) => {
table.toggleAllPageRowsSelected(!!(event.target as HTMLInputElement).checked);
}
});
},
cell: ({ row }) => {
const checkboxSnippet = createRawSnippet<[
{ selected: boolean; onchange: (event: Event) => void }
]>((getProps) => {
const { selected, onchange } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" tabindex="-1" class="h-4 w-4 cursor-pointer" ${selected ? 'checked' : ''} />
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement | null;
if (!input) return;
input.addEventListener('click', (event) => event.stopPropagation());
input.addEventListener('change', onchange);
}
};
});
return renderSnippet(checkboxSnippet, {
selected: row.getIsSelected(),
onchange: (event: Event) => {
event.stopPropagation();
row.toggleSelected(!!(event.target as HTMLInputElement).checked);
}
});
},
enableSorting: false,
enableHiding: false
},
{
accessorKey: 'id',
header: 'Consecutivo',
@@ -48,6 +125,7 @@ export function createColumns(
{
id: 'actions',
header: 'Acciones',
size: 88,
cell: ({ row }) =>
renderComponent(DataTableActions, {
item: row.original,

View File

@@ -5,17 +5,19 @@
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import * as Select from '$lib/components/ui/select';
import { LoaderCircle } from 'lucide-svelte';
import { LoaderCircle, Search } from 'lucide-svelte';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type ExpedienteArchivoCreateDTO
} from '$lib/api/dashboard/a76/expediente-archivos';
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import {
documentTypesDigitizationApi,
type DocumentTypeDigitization
} from '$lib/api/dashboard/reference_data/document_types_digitization';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import PedimentoSelectorDialog from '$lib/components/dashboard/pedimentos/edit/pedimento-selector-dialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import * as m from '$lib/paraglide/messages';
@@ -48,8 +50,12 @@
// Buscar en la lista ya cargada primero
const found = brokers.find((b) => b.license === licenseKey);
if (found?.tax_id?.trim()) {
formData.rfc_consulta = found.tax_id.trim().toUpperCase();
return;
}
if (found?.vu?.query_tax_id) {
formData.rfc_consulta = found.vu.query_tax_id;
formData.rfc_consulta = found.vu.query_tax_id.trim().toUpperCase();
return;
}
@@ -57,10 +63,15 @@
const company = companyStore.activeCompany;
if (!company) return;
try {
const res = await customsBrokersApi.get(licenseKey, company.id.toString());
const brokerKey = found?.broker_key || licenseKey;
const res = await customsBrokersApi.get(brokerKey, company.id.toString());
const broker = (res.data || res) as CustomsBroker;
if (broker?.tax_id?.trim()) {
formData.rfc_consulta = broker.tax_id.trim().toUpperCase();
return;
}
if (broker?.vu?.query_tax_id) {
formData.rfc_consulta = broker.vu.query_tax_id;
formData.rfc_consulta = broker.vu.query_tax_id.trim().toUpperCase();
}
} catch {
// VU no disponible, dejar rfc vacío
@@ -82,21 +93,35 @@
let loading = $state(false);
let error = $state<string | null>(null);
let isPedimentoDialogOpen = $state(false);
let selectedFile = $state<File | null>(null);
const selectedDocType = $derived(docTypes.find((d) => d.code === formData.tipo_documento) ?? null);
const selectedBroker = $derived(
brokers.find((broker) => broker.license === formData.agente_aduanal) ?? null
);
function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
function handlePedimentoSelect(pedimento: Pedimento) {
formData.pedimento = buildPedimentoLabel(pedimento);
}
// ── Efectos ────────────────────────────────────────────────────────────── //
$effect(() => {
if (open && companyStore.activeCompany?.id) {
const companyId = companyStore.activeCompany.id;
// Cargar tipos de documento
if (docTypes.length === 0) {
docTypesLoading = true;
documentTypesDigitizationApi
.getAll(true)
.getAll(companyId, true)
.then((res) => {
docTypes = (res.data as DocumentTypeDigitization[]) || [];
docTypes = res.data?.items || [];
})
.catch(() => (docTypes = []))
.finally(() => (docTypesLoading = false));
@@ -121,6 +146,7 @@
if (!open) {
error = null;
loading = false;
selectedFile = null;
return;
}
if (item) {
@@ -192,6 +218,7 @@
};
let response;
let createdId: number | null = null;
if (isEdit && item) {
response = await expedienteArchivosApi.update(item.id, payload, company.id);
} else {
@@ -204,6 +231,17 @@
throw new Error(response.error);
}
createdId = response.data?.id ?? item?.id ?? null;
if (selectedFile && createdId) {
const uploadResponse = await expedienteArchivosApi.uploadFile(createdId, selectedFile, company.id);
if (uploadResponse.error) {
if (!isEdit) {
await expedienteArchivosApi.delete(createdId, company.id);
}
throw new Error(uploadResponse.error);
}
}
open = false;
onSuccess?.();
} catch (e) {
@@ -225,12 +263,10 @@
{#if isEdit}
<Dialog.Description>
Modifica el documento digitalizado <span class="font-mono font-semibold">{item?.id}</span>.
El RFC Consulta se obtiene del VU del agente aduanal.
</Dialog.Description>
{:else}
<Dialog.Description>
Captura un nuevo documento digitalizado. El RFC Consulta se obtiene del VU del
agente aduanal.
Captura un nuevo documento digitalizado.
</Dialog.Description>
{/if}
</Dialog.Header>
@@ -288,26 +324,16 @@
<Label for="archivo_digitalizado_en">{m['sidebar.digitalizacion.form_archivo_digitalizado_en']()} *</Label>
<FilePickerInput
id="archivo_digitalizado_en"
value={formData.archivo_digitalizado_en ?? ''}
value={formData.nombre_archivo ?? formData.archivo_digitalizado_en ?? ''}
placeholder="Seleccionar archivo..."
disabled={loading}
onchange={(file) => {
selectedFile = file;
formData.archivo_digitalizado_en = file.name;
if (!formData.nombre_archivo) formData.nombre_archivo = file.name;
formData.nombre_archivo = file.name;
}}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="nombre_archivo">{m['sidebar.digitalizacion.form_nombre_archivo']()}</Label>
<Input
id="nombre_archivo"
value={formData.nombre_archivo ?? ''}
oninput={(e) => (formData.nombre_archivo = (e.target as HTMLInputElement).value)}
placeholder="nombre_archivo.pdf"
disabled={loading}
/>
</div>
</div>
</div>
@@ -365,26 +391,27 @@
</Select.Root>
</div>
<div class="space-y-2">
<Label for="rfc_consulta">RFC Consulta</Label>
<Input
id="rfc_consulta"
value={formData.rfc_consulta ?? ''}
placeholder="Se llena automáticamente del agente aduanal"
maxlength={13}
disabled
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="pedimento">{m['sidebar.digitalizacion.form_pedimento']()}</Label>
<Input
id="pedimento"
value={formData.pedimento ?? ''}
oninput={(e) => (formData.pedimento = (e.target as HTMLInputElement).value)}
placeholder="00-0000-0000000"
disabled={loading}
/>
<div class="flex gap-2">
<Input
id="pedimento"
value={formData.pedimento ?? ''}
placeholder="Selecciona desde el catálogo de pedimentos"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button
type="button"
variant="outline"
size="icon"
onclick={() => (isPedimentoDialogOpen = true)}
disabled={loading}
>
<Search class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
@@ -404,3 +431,5 @@
</form>
</Dialog.Content>
</Dialog.Root>
<PedimentoSelectorDialog bind:open={isPedimentoDialogOpen} onSelect={handlePedimentoSelect} />

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, FileCheck2, Download } from 'lucide-svelte';
import { Ellipsis, FileCheck2, Download, FolderArchive, Pencil, Trash2 } from 'lucide-svelte';
import { expedienteArchivosApi, type ExpedienteArchivo } from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
@@ -10,18 +10,48 @@
let {
item,
onSuccess,
onDigitalizar,
onAcuse
onDigitalizar
}: {
item: ExpedienteArchivo;
onSuccess?: () => void;
onDigitalizar?: (item: ExpedienteArchivo) => void;
onAcuse?: (item: ExpedienteArchivo) => void;
} = $props();
let loading = $state(false);
let editOpen = $state(false);
async function downloadArtifact(
type: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
filename: string
) {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadArtifact(
item.id,
type,
companyStore.activeCompany.id,
filename
);
} catch {
alert('Error al descargar el archivo.');
}
}
const baseName = $derived((item.nombre_archivo || String(item.id)).replace(/\.[^.]+$/, ''));
async function downloadZip() {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadAllArtifactsZip(
item.id,
companyStore.activeCompany.id,
item.e_document || String(item.id)
);
} catch {
alert('Error al descargar el ZIP.');
}
}
async function handleDelete() {
if (!confirm(m['sidebar.digitalizacion.confirm_delete']())) return;
if (!companyStore.activeCompany) return;
@@ -44,27 +74,70 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" disabled={loading}>
<EllipsisVertical class="h-4 w-4" />
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0" disabled={loading}>
<span class="sr-only">Abrir menú</span>
<Ellipsis class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => onDigitalizar?.(item)}>
<FileCheck2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_digitalizar']()}
</DropdownMenu.Item>
{#if item.status === 'success'}
<DropdownMenu.Item onclick={() => onAcuse?.(item)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
<DropdownMenu.Item onclick={downloadZip}>
<FolderArchive class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_download_zip']()}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (editOpen = true)}>
{#if item.status === 'success'}
<DropdownMenu.Item onclick={() => downloadArtifact('acuse', `acuse_${baseName}.pdf`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
</DropdownMenu.Item>
{/if}
{#if item.envio_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('envio-xml', `envio_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_envio_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.respuesta_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('respuesta-xml', `respuesta_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_respuesta_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.consulta_envio_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('consulta-envio-xml', `consulta_envio_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_envio_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.consulta_respuesta_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('consulta-respuesta-xml', `consulta_respuesta_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_respuesta_xml']()}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (editOpen = true)}>
<Pencil class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_edit']()}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={handleDelete}

View File

@@ -14,12 +14,16 @@
open = $bindable(false),
taskId,
nombreArchivo = '',
recordId,
companyId,
onComplete,
onCancel
}: {
open: boolean;
taskId: string;
nombreArchivo?: string;
recordId?: number;
companyId?: string | number;
onComplete?: (result: DigitalizacionResult) => void;
onCancel?: () => void;
} = $props();
@@ -32,12 +36,18 @@
let result = $state<DigitalizacionResult | null>(null);
let errorMsg = $state<string | null>(null);
let errorDetail = $state<DigitalizacionErrorDetail | null>(null);
let pollHandle = $state<ReturnType<typeof setInterval> | null>(null);
let externalTaskId = $state<string | null>(null);
let requestId = $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;
// Start / stop polling based on open + taskId
$effect(() => {
if (open && taskId) {
startPolling();
void startPolling();
} else {
stopPolling();
if (!open) resetState();
@@ -53,58 +63,134 @@
result = null;
errorMsg = null;
errorDetail = null;
externalTaskId = null;
requestId = null;
consecutivePollErrors = 0;
}
function startPolling() {
async function startPolling() {
if (pollingActive && pollingTaskId === taskId) return;
stopPolling();
poll(); // immediate first call
pollHandle = setInterval(poll, 2000);
pollingActive = true;
pollingTaskId = taskId;
await poll();
}
function stopPolling() {
pollingActive = false;
pollingTaskId = null;
if (pollHandle !== null) {
clearInterval(pollHandle);
clearTimeout(pollHandle);
pollHandle = null;
}
}
function scheduleNextPoll(delayMs = 1000) {
if (!pollingActive || !taskId) return;
if (pollHandle !== null) {
clearTimeout(pollHandle);
}
pollHandle = setTimeout(() => {
void poll();
}, delayMs);
}
async function poll() {
if (!taskId) return;
if (!taskId || !pollingActive || pollInFlight) return;
pollInFlight = true;
try {
const res = await expedienteArchivosApi.getStatusTask(taskId);
if (!res.data) return;
const data = res.data;
if (res.error) {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3 || res.status >= 500) {
state = 'FAILURE';
errorMsg = res.error || 'No se pudo consultar el estado de la digitalización';
errorDetail = {
codigo: 'TASK_STATUS_REQUEST_ERROR',
descripcion: res.error || 'El servidor devolvió un error al consultar el estado.',
paso: 'Consulta de estado',
sugerencias: ['Cierra el diálogo y vuelve a intentar la digitalización.']
};
stopPolling();
} else {
scheduleNextPoll();
}
return;
}
state = (data.state || 'PENDING') as TaskState;
if (!res.data) {
scheduleNextPoll();
return;
}
const data = res.data;
consecutivePollErrors = 0;
externalTaskId = data.external_task_id ?? externalTaskId;
requestId = data.request_id ?? requestId;
state = (data.state === 'FAILED' ? 'FAILURE' : data.state || 'PENDING') as TaskState;
currentStep = data.current_step || 'Procesando...';
progress = data.progress ?? 0;
if (data.state === 'SUCCESS' && data.result) {
result = data.result;
if (data.state === 'SUCCESS') {
result = data.result ?? null;
stopPolling();
onComplete?.(data.result);
} else if (data.state === 'FAILURE') {
onComplete?.(data.result ?? ({} as DigitalizacionResult));
} else if (data.state === 'FAILURE' || data.state === 'FAILED') {
errorMsg = data.error || 'Error en la digitalización';
errorDetail = data.error_detail ?? null;
stopPolling();
} else {
scheduleNextPoll();
}
} catch {
// Ignore transient poll errors
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = 'No se pudo consultar el estado de la digitalización';
errorDetail = {
codigo: 'TASK_STATUS_NETWORK_ERROR',
descripcion: 'La consulta de estado falló repetidamente.',
paso: 'Consulta de estado',
sugerencias: ['Verifica la conexión y vuelve a intentar la digitalización.']
};
stopPolling();
} else {
scheduleNextPoll();
}
} finally {
pollInFlight = false;
}
}
function downloadAcuse() {
if (!result?.acuese_digitalizacion_pdf_base64) return;
const bytes = Uint8Array.from(atob(result.acuese_digitalizacion_pdf_base64), (c) =>
c.charCodeAt(0)
);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `acuse_${nombreArchivo || taskId}.pdf`;
a.click();
URL.revokeObjectURL(url);
async function downloadAcuse() {
const baseName = (nombreArchivo || taskId).replace(/\.[^.]+$/, '');
if (recordId != null && companyId != null) {
try {
await expedienteArchivosApi.downloadArtifact(
recordId,
'acuse',
companyId,
`acuse_${baseName}.pdf`
);
} catch {
alert('Error al descargar el acuse.');
}
} else if (result?.acuese_digitalizacion_pdf_base64) {
// fallback: decode base64 locally (registros legacy)
const bytes = Uint8Array.from(atob(result.acuese_digitalizacion_pdf_base64), (c) =>
c.charCodeAt(0)
);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `acuse_${baseName}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
function handleCancel() {
@@ -148,7 +234,7 @@
{/if}
</dl>
{/if}
{#if result?.acuese_digitalizacion_pdf_base64}
{#if result?.acuese_digitalizacion_pdf_base64 || (recordId != null && companyId != null)}
<Button onclick={downloadAcuse} class="w-full gap-2">
<Download class="h-4 w-4" />
{m['sidebar.digitalizacion.progress_download_acuse']()}
@@ -190,6 +276,21 @@
<p class="text-xs text-right text-muted-foreground">{progress}%</p>
</div>
{/if}
{#if taskId || externalTaskId || requestId}
<div class="rounded-md border bg-muted/30 px-3 py-2 space-y-1">
<p class="text-xs text-muted-foreground">Task App:</p>
<p class="text-xs font-mono break-all">{taskId}</p>
{#if externalTaskId}
<p class="text-xs text-muted-foreground pt-1">Task API:</p>
<p class="text-xs font-mono break-all">{externalTaskId}</p>
{/if}
{#if requestId}
<p class="text-xs text-muted-foreground pt-1">Request ID:</p>
<p class="text-xs font-mono break-all">{requestId}</p>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="flex justify-end">

View File

@@ -34,11 +34,15 @@
ChevronsRight
} from 'lucide-svelte';
import { documentTypesDigitizationApi, type DocumentTypeDigitization } from '$lib/api/dashboard/reference_data/document_types_digitization';
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import { companyStore } from '$lib/stores/company.svelte';
import PedimentoSelectorDialog from '$lib/components/dashboard/pedimentos/edit/pedimento-selector-dialog.svelte';
interface Digitalizacion {
id?: number;
linea: number;
tipo_documento: string;
pedimento: string;
ruta_archivo_pdf: string;
observaciones: string;
e_document: string;
@@ -59,27 +63,26 @@
let isDialogOpen = $state(false);
let editingIndex = $state<number | null>(null);
let isTipoDocumentoDialogOpen = $state(false);
let isNuevoTipoDocumentoDialogOpen = $state(false);
let isPedimentoDialogOpen = $state(false);
// Tipos de documentos disponibles (cargados desde el backend)
let tiposDocumentos = $state<DocumentTypeDigitization[]>([]);
let isLoadingTiposDocumentos = $state(false);
let errorLoadingTiposDocumentos = $state<string | null>(null);
// Formulario para nuevo tipo de documento
let nuevoTipoDocumento = $state({
code: '',
description: '',
active: true
});
// Cargar tipos de documentos desde el backend
async function cargarTiposDocumentos() {
try {
isLoadingTiposDocumentos = true;
errorLoadingTiposDocumentos = null;
const response = await documentTypesDigitizationApi.getAll(true);
tiposDocumentos = response.data || [];
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
errorLoadingTiposDocumentos = 'No hay empresa activa seleccionada';
tiposDocumentos = [];
return;
}
const response = await documentTypesDigitizationApi.getAll(companyId, true);
tiposDocumentos = response.data?.items || [];
} catch (error) {
console.error('Error al cargar tipos de documentos:', error);
errorLoadingTiposDocumentos = 'Error al cargar los tipos de documentos';
@@ -111,6 +114,7 @@
let currentDigitalizacion = $state<Digitalizacion>({
linea: 0,
tipo_documento: '',
pedimento: '',
ruta_archivo_pdf: '',
observaciones: '',
e_document: '',
@@ -139,6 +143,7 @@
currentDigitalizacion = {
linea: nextLinea,
tipo_documento: '',
pedimento: '',
ruta_archivo_pdf: '',
observaciones: '',
e_document: '',
@@ -275,31 +280,11 @@
isTipoDocumentoDialogOpen = false;
}
function abrirNuevoTipoDocumento() {
nuevoTipoDocumento = {
code: '',
description: '',
active: true
};
isNuevoTipoDocumentoDialogOpen = true;
}
async function guardarNuevoTipoDocumento() {
try {
const response = await documentTypesDigitizationApi.create(nuevoTipoDocumento);
if (response.data) {
// Agregar el nuevo tipo a la lista
tiposDocumentos = [...tiposDocumentos, response.data];
// Seleccionar el nuevo tipo
currentDigitalizacion.tipo_documento = response.data.code;
// Cerrar ambos dialogs
isNuevoTipoDocumentoDialogOpen = false;
isTipoDocumentoDialogOpen = false;
}
} catch (error) {
console.error('Error al crear tipo de documento:', error);
alert('Error al crear el tipo de documento');
}
function seleccionarPedimento(pedimento: Pedimento) {
currentDigitalizacion.pedimento = `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
</script>
@@ -490,14 +475,36 @@
<div class="flex gap-2">
<Input
id="tipo_documento"
bind:value={currentDigitalizacion.tipo_documento}
placeholder=""
class="flex-1"
value={currentDigitalizacion.tipo_documento}
placeholder="Selecciona desde el catálogo"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button size="icon" variant="outline" onclick={abrirTiposDocumentos}>
<Button size="icon" variant="outline" onclick={abrirTiposDocumentos} type="button">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
Solo lectura. Usa el botón para elegir desde el catálogo de tipos de documento.
</p>
</div>
<div class="space-y-2">
<Label for="pedimento">Pedimento</Label>
<div class="flex gap-2">
<Input
id="pedimento"
value={currentDigitalizacion.pedimento}
placeholder="Selecciona desde el catálogo de pedimentos"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button size="icon" variant="outline" onclick={() => (isPedimentoDialogOpen = true)} type="button">
<Search class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
@@ -555,6 +562,8 @@
</DialogContent>
</Dialog>
<PedimentoSelectorDialog bind:open={isPedimentoDialogOpen} onSelect={seleccionarPedimento} />
<!-- Dialog para Seleccionar Tipo de Documento -->
<Dialog bind:open={isTipoDocumentoDialogOpen}>
<DialogContent class="!max-w-[50vw] !w-[50vw] max-h-[85vh] h-[85vh] flex flex-col p-6">
@@ -563,7 +572,7 @@
</DialogHeader>
<div class="space-y-4 flex-1 min-h-0 flex flex-col overflow-hidden">
<!-- Campo de búsqueda y botón nuevo -->
<!-- Campo de búsqueda -->
<div class="flex items-center gap-2 shrink-0">
<Label for="buscar_tipo" class="whitespace-nowrap min-w-[60px]">Buscar:</Label>
<Input
@@ -572,10 +581,6 @@
placeholder="Buscar por código o descripción..."
class="flex-1"
/>
<Button size="sm" onclick={abrirNuevoTipoDocumento}>
<Plus class="mr-1.5" size={14} />
Nuevo
</Button>
</div>
<!-- Tabla de tipos de documentos -->
@@ -630,62 +635,7 @@
variant="default"
onclick={() => (isTipoDocumentoDialogOpen = false)}
>
Seleccionar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Dialog para Nuevo Tipo de Documento -->
<Dialog bind:open={isNuevoTipoDocumentoDialogOpen}>
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>Tipos de Documentos para Digitalización</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- Clave -->
<div class="space-y-2">
<Label for="nuevo_code">Clave:</Label>
<Input
id="nuevo_code"
bind:value={nuevoTipoDocumento.code}
placeholder="Ingrese la clave"
maxlength={10}
/>
</div>
<!-- Documento -->
<div class="space-y-2">
<Label for="nuevo_description">Documento:</Label>
<Input
id="nuevo_description"
bind:value={nuevoTipoDocumento.description}
placeholder="Descripción del documento"
/>
</div>
<!-- Detalle (campo de texto largo) -->
<div class="space-y-2">
<Label for="nuevo_detalle">Detalle:</Label>
<Textarea
id="nuevo_detalle"
placeholder="Información adicional..."
rows={4}
class="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (isNuevoTipoDocumentoDialogOpen = false)}>
Cancelar
</Button>
<Button
onclick={guardarNuevoTipoDocumento}
disabled={!nuevoTipoDocumento.code || !nuevoTipoDocumento.description}
>
Aceptar
Cerrar
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import { Search, Loader2, FileText } from 'lucide-svelte';
import { pedimentosApi, type Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (pedimento: Pedimento) => void;
} = $props();
let pedimentos = $state<Pedimento[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let loaded = $state(false);
let page = $state(1);
let pageSize = 50;
let totalItems = $state(0);
let hasMore = $state(true);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let scrollContainer: HTMLDivElement | null = $state(null);
function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
let filteredPedimentos = $derived(
pedimentos.filter((pedimento) => {
const label = buildPedimentoLabel(pedimento).toLowerCase();
const code = (pedimento.pedimento_code || '').toLowerCase();
const regime = (pedimento.regime || '').toLowerCase();
const status = (pedimento.status || '').toLowerCase();
const term = searchTerm.toLowerCase();
return (
label.includes(term) ||
code.includes(term) ||
regime.includes(term) ||
status.includes(term) ||
pedimento.id.toString().includes(searchTerm)
);
})
);
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
void resetAndLoad();
} else if (!open) {
loaded = false;
page = 1;
hasMore = true;
totalItems = 0;
pedimentos = [];
}
});
$effect(() => {
if (bottomSentinel && scrollContainer && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
void loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
hasMore = true;
totalItems = 0;
pedimentos = [];
await loadPedimentos(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadPedimentos(false);
}
async function loadPedimentos(isInitial: boolean) {
if (!companyStore.activeCompany?.id) return;
if (isInitial) loading = true;
else loadingMore = true;
try {
const res = await pedimentosApi.list(page, pageSize, undefined, companyStore.activeCompany.id);
const responseData = (res as any).data || res;
if (responseData?.items) {
const newItems = responseData.items as Pedimento[];
totalItems = responseData.total || 0;
pedimentos = isInitial ? newItems : [...pedimentos, ...newItems];
hasMore = pedimentos.length < totalItems && newItems.length > 0;
loaded = true;
} else {
hasMore = false;
}
} catch (e) {
console.error('Error cargando pedimentos:', e);
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(pedimento: Pedimento) {
onSelect?.(pedimento);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col z-[300]">
<Dialog.Header>
<Dialog.Title>Seleccionar Pedimento</Dialog.Title>
<Dialog.Description>
Busca y selecciona el pedimento registrado para asociarlo a la digitalización.
</Dialog.Description>
</Dialog.Header>
<div class="relative w-full my-2">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por pedimento, clave, régimen o ID..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading && pedimentos.length === 0}
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredPedimentos.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron pedimentos.</p>
</div>
{:else}
<table class="w-full text-sm">
<thead class="bg-muted/50 backdrop-blur-sm">
<tr class="text-left border-b">
<th class="p-3 font-medium text-muted-foreground w-[60px]">ID</th>
<th class="p-3 font-medium text-muted-foreground w-[220px]">Pedimento</th>
<th class="p-3 font-medium text-muted-foreground w-[120px]">Clave</th>
<th class="p-3 font-medium text-muted-foreground">Régimen</th>
<th class="p-3 font-medium text-muted-foreground w-[110px] text-center">Estado</th>
</tr>
</thead>
<tbody>
{#each filteredPedimentos as pedimento}
<tr
class="border-b hover:bg-accent/50 transition-colors cursor-pointer"
onclick={() => handleSelect(pedimento)}
>
<td class="p-3 font-mono text-xs">{pedimento.id}</td>
<td class="p-3 font-medium">
<div class="flex items-center gap-2">
<FileText class="h-3 w-3 text-blue-500" />
<span class="font-mono text-xs">{buildPedimentoLabel(pedimento)}</span>
</div>
</td>
<td class="p-3 font-mono text-xs">{pedimento.pedimento_code || '-'}</td>
<td class="p-3">{pedimento.regime || '-'}</td>
<td class="p-3 text-center">
{#if pedimento.status}
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
{pedimento.status}
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
Sin estado
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
Mostrando {filteredPedimentos.length} de {totalItems || pedimentos.length} registro(s) cargados
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,59 @@
import type { ColumnDef } from '@tanstack/table-core';
import { renderSnippet } from '$lib/components/ui/data-table/index.js';
import { createRawSnippet } from 'svelte';
import type { DocumentTypeDigitization } from '$lib/api/dashboard/reference_data/document_types_digitization';
export function createColumns(): ColumnDef<DocumentTypeDigitization>[] {
return [
{
accessorKey: 'code',
header: 'Código',
cell: ({ row }) => {
const keySnippet = createRawSnippet<[{ code: string }]>((getCode) => {
const { code } = getCode();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${code}</code>`
};
});
return renderSnippet(keySnippet, { code: row.original.code });
}
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => {
const descriptionSnippet = createRawSnippet<[{ description: string }]>((getDescription) => {
const { description } = getDescription();
return {
render: () => `<div class="max-w-[720px] whitespace-normal">${description}</div>`
};
});
return renderSnippet(descriptionSnippet, { description: row.original.description });
}
},
{
accessorKey: 'active',
header: 'Estatus',
cell: ({ row }) => {
const activeSnippet = createRawSnippet<[{ active: boolean }]>((getActive) => {
const { active } = getActive();
const className = active
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-600';
const label = active ? 'Activo' : 'Inactivo';
return {
render: () =>
`<span class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${className}">${label}</span>`
};
});
return renderSnippet(activeSnippet, { active: row.original.active });
}
}
];
}
export const columns = createColumns();

View File

@@ -122,6 +122,10 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.reference_data.incoterms"](),
url: "/dashboard/reference_data/incoterms",
},
{
title: m["sidebar.reference_data.document_types_digitization"](),
url: "/dashboard/reference_data/document_types_digitization",
},
{
title: m["sidebar.reference_data.invoice_types"](),
url: "/dashboard/reference_data/invoice_types",

View File

@@ -0,0 +1,59 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosDigitalizacion = (acciones: {
manejarNuevo: () => void;
manejarActualizar: () => void;
manejarDigitalizar: () => void;
manejarEditar: () => void;
manejarEliminar: () => void;
manejarDescargarZip: () => void;
irATabla: () => void;
irAAcciones: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Shift+N',
description: 'Nuevo Documento',
action: acciones.manejarNuevo,
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.manejarActualizar,
},
{
key: 'Alt+Shift+T',
description: 'Ir a tabla',
action: acciones.irATabla,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+A',
description: 'Ir a acciones (barra inferior)',
action: acciones.irAAcciones,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+J',
description: 'Digitalizar seleccionado',
action: acciones.manejarDigitalizar,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+E',
description: 'Editar seleccionado',
action: acciones.manejarEditar,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+Z',
description: 'Descargar ZIP (todos los artefactos)',
action: acciones.manejarDescargarZip,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+X',
description: 'Eliminar seleccionados',
action: acciones.manejarEliminar,
skipDefaultFocusAfter: true,
},
];

View File

@@ -1,26 +1,27 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw } from 'lucide-svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import * as m from '$lib/paraglide/messages';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2 } from 'lucide-svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte';
import DigitalizarDialog from '$lib/components/dashboard/digitalizacion/digitalizar-dialog.svelte';
import ProgressDialog from '$lib/components/dashboard/digitalizacion/progress-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/digitalizacion/columns';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type DigitalizacionResult
} from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import * as m from '$lib/paraglide/messages';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte';
import ProgressDialog from '$lib/components/dashboard/digitalizacion/progress-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/digitalizacion/columns';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type DigitalizacionResult
} from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosDigitalizacion } from '$lib/config/shortcuts/dashboard/a76/digitalizacion/list';
// ── Estado ─────────────────────────────────────────────────────────────── //
let data = $state<ExpedienteArchivo[]>([]);
let totalItems = $state(0);
@@ -32,16 +33,27 @@
let search = $state($page.url.searchParams.get('search') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
// Selección de filas
let selectedIds = $state<number[]>([]);
const selectedItems = $derived(data.filter((item) => selectedIds.includes(item.id)));
const selectedItem = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedItem?.acuse_pdf_path);
const canDownloadEnvioXml = $derived(!!selectedItem?.envio_xml_path);
const canDownloadRespuestaXml = $derived(!!selectedItem?.respuesta_xml_path);
const canDownloadConsultaEnvioXml = $derived(!!selectedItem?.consulta_envio_xml_path);
const canDownloadConsultaRespuestaXml = $derived(!!selectedItem?.consulta_respuesta_xml_path);
const canDownloadZip = $derived(selectedItem?.status === 'success');
// Dialogs
let createDialogOpen = $state(false);
let digitalizarDialogOpen = $state(false);
let editDialogOpen = $state(false);
let progressDialogOpen = $state(false);
let selectedItem = $state<ExpedienteArchivo | null>(null);
let editingItem = $state<ExpedienteArchivo | null>(null);
let digitalizarItem = $state<ExpedienteArchivo | null>(null);
let currentTaskId = $state<string>('');
let currentNombreArchivo = $state<string>('');
// Acuses por session (id → base64)
let acuseMap = $state<Record<number, string>>({});
// ── Carga de datos ─────────────────────────────────────────────────────── //
async function loadData() {
@@ -57,6 +69,7 @@
data = res.data.items;
currentPage = 1;
totalItems = res.data.total;
selectedIds = selectedIds.filter((id) => data.some((item) => item.id === id));
}
} catch (e) {
console.error('Error loading expediente archivos:', e);
@@ -103,46 +116,260 @@
});
// ── Handlers de acciones ───────────────────────────────────────────────── //
function handleDigitalizar(item: ExpedienteArchivo) {
selectedItem = item;
digitalizarDialogOpen = true;
function handleRowClick(item: ExpedienteArchivo) {
if (selectedIds.includes(item.id)) {
selectedIds = selectedIds.filter((id) => id !== item.id);
return;
}
selectedIds = [...selectedIds, item.id];
}
function handleSelectedIdsChange(ids: number[]) {
selectedIds = ids;
}
async function handleDigitalizar(item: ExpedienteArchivo) {
const company = companyStore.activeCompany;
if (!company) {
alert('No hay compañía seleccionada.');
return;
}
if (!item.tipo_documento?.trim()) {
alert('El documento no tiene Clave Documento capturada.');
return;
}
if (!item.nombre_archivo?.trim() || !item.archivo_digitalizado_en?.trim()) {
alert('El documento no tiene archivo cargado. Edita el registro y vuelve a seleccionar el archivo.');
return;
}
digitalizarItem = item;
const response = await expedienteArchivosApi.digitalizar(
item.id,
{
rfc_consulta: item.rfc_consulta?.trim() || undefined,
clave_documento: item.tipo_documento.trim(),
nombre_archivo: item.nombre_archivo.trim(),
archivo_base64: undefined
},
company.id
);
if (response.error) {
const ve = (response as any).validationErrors;
if (ve?.length) {
alert(ve.map((error: any) => error.msg).join(' · '));
return;
}
alert(response.error);
return;
}
const taskId = response.data?.task_id;
if (!taskId) {
alert('No se recibió task_id del servidor');
return;
}
handleDigitalizarSuccess(taskId);
}
async function handleDigitalizarSelected() {
if (!selectedItem) {
alert('Selecciona un documento para digitalizar.');
return;
}
await handleDigitalizar(selectedItem);
}
function handleDigitalizarSuccess(taskId: string) {
currentTaskId = taskId;
currentNombreArchivo = selectedItem?.nombre_archivo ?? '';
currentNombreArchivo = digitalizarItem?.nombre_archivo ?? '';
progressDialogOpen = true;
}
function handleProgressComplete(result: DigitalizacionResult) {
// Guardar acuse en sesión si viene incluido
if (selectedItem && result.acuese_digitalizacion_pdf_base64) {
acuseMap = { ...acuseMap, [selectedItem.id]: result.acuese_digitalizacion_pdf_base64 };
}
function handleProgressComplete(_result: DigitalizacionResult) {
loadData();
}
function handleAcuse(item: ExpedienteArchivo) {
const b64 = acuseMap[item.id];
if (!b64) {
alert('No hay acuse disponible para este documento en esta sesión.');
async function handleDownloadArtifact(
item: ExpedienteArchivo,
type: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
filename: string
) {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadArtifact(item.id, type, companyStore.activeCompany.id, filename);
} catch {
alert('Error al descargar el archivo.');
}
}
async function handleAcuse(item: ExpedienteArchivo) {
const baseName = (item.nombre_archivo || String(item.id)).replace(/\.[^.]+$/, '');
await handleDownloadArtifact(item, 'acuse', `acuse_${baseName}.pdf`);
}
function handleAcuseSelected() {
if (!selectedItem) {
alert('Selecciona un documento para descargar el acuse.');
return;
}
handleAcuse(selectedItem);
}
function handleDownloadSelected(
type: 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
prefix: string,
ext: string
) {
if (!selectedItem) return;
const baseName = (selectedItem.nombre_archivo || String(selectedItem.id)).replace(/\.[^.]+$/, '');
handleDownloadArtifact(selectedItem, type, `${prefix}_${baseName}.${ext}`);
}
async function handleDownloadZip() {
if (!selectedItem || !companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadAllArtifactsZip(
selectedItem.id,
companyStore.activeCompany.id,
selectedItem.e_document || String(selectedItem.id)
);
} catch {
alert('Error al descargar el ZIP.');
}
}
function handleEditSelected() {
if (!selectedItem) {
alert('Selecciona un documento para editar.');
return;
}
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `acuse_${item.nombre_archivo || item.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
editingItem = selectedItem;
editDialogOpen = true;
}
async function handleFooterDelete() {
if (!companyStore.activeCompany || selectedIds.length === 0) return;
const confirmed =
selectedIds.length === 1
? confirm(m['sidebar.digitalizacion.confirm_delete']())
: confirm(`Se eliminarán ${selectedIds.length} documentos digitalizados. ¿Deseas continuar?`);
if (!confirmed) return;
try {
const results = await Promise.all(
selectedIds.map((id) => expedienteArchivosApi.delete(id, companyStore.activeCompany!.id))
);
const firstError = results.find((result) => result.error)?.error;
if (firstError) {
alert(`Error al eliminar: ${firstError}`);
return;
}
selectedIds = [];
await loadData();
} catch (e) {
alert(`Error: ${e instanceof Error ? e.message : 'Error desconocido'}`);
}
}
const columns = createColumns(loadData, handleDigitalizar, handleAcuse);
// ── Navegación por teclado ─────────────────────────────────────────────── //
function focusFirstTableRow() {
if (!browser) return;
const row = document.querySelector<HTMLElement>(
'[data-digitalizacion-list-table] tbody tr[data-slot="table-row"]'
);
if (row) {
row.focus();
setTimeout(() => row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50);
return;
}
toast.info('No hay filas en la tabla');
}
function focusFooterActions() {
if (!browser) return;
const footer = document.getElementById('digitalizacion-list-footer');
if (!footer) return;
const candidates = footer.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
);
for (const el of candidates) {
if (el.offsetParent === null && el.getClientRects().length === 0) continue;
el.focus();
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50);
return;
}
}
function getFooterToolbarButtons(): HTMLButtonElement[] {
const toolbar = document.querySelector('#digitalizacion-list-footer [data-digitalizacion-footer-toolbar]');
if (!toolbar) return [];
return Array.from(toolbar.querySelectorAll<HTMLButtonElement>('[data-footer-action]'));
}
function handleFooterToolbarKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const target = event.target as HTMLElement | null;
if (!target?.closest('[data-digitalizacion-footer-toolbar]')) return;
if (target.closest('[data-slot="dropdown-menu-content"]')) return;
const buttons = getFooterToolbarButtons().filter((b) => !b.disabled);
if (buttons.length === 0) return;
const active = document.activeElement as HTMLButtonElement | null;
let idx = active ? buttons.indexOf(active) : -1;
if (idx === -1) {
idx = event.key === 'ArrowRight' ? 0 : buttons.length - 1;
} else if (event.key === 'ArrowRight') {
idx = (idx + 1) % buttons.length;
} else {
idx = (idx - 1 + buttons.length) % buttons.length;
}
event.preventDefault();
buttons[idx]?.focus();
}
useShortcuts(
'Digitalizacion List',
obtenerAtajosDigitalizacion({
manejarNuevo: () => (createDialogOpen = true),
manejarActualizar: loadData,
manejarDigitalizar: handleDigitalizarSelected,
manejarEditar: handleEditSelected,
manejarEliminar: () => {
if (selectedIds.length === 0) {
toast.info('Selecciona al menos un documento para eliminar');
return;
}
handleFooterDelete();
},
manejarDescargarZip: () => {
if (!selectedItem || !canDownloadZip) {
toast.info('Selecciona un documento digitalizado para descargar ZIP');
return;
}
handleDownloadZip();
},
irATabla: focusFirstTableRow,
irAAcciones: focusFooterActions,
})
);
</script>
<div
class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden"
class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden pb-[5.5rem]"
>
<!-- Header -->
<div class="flex items-center justify-between">
@@ -157,10 +384,6 @@
<RefreshCw class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.refresh']()}
</Button>
<Button class="h-9" onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.new']()}
</Button>
</div>
</div>
@@ -189,8 +412,17 @@
{m['sidebar.digitalizacion.empty']()}
</div>
{:else}
<div class="rounded-md border bg-background overflow-hidden h-full">
<InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} />
<div class="rounded-md border bg-background overflow-hidden h-full" data-digitalizacion-list-table>
<InfiniteDataTable
{data}
{columns}
{loading}
{hasMore}
{loadMore}
{selectedIds}
onSelectedIdsChange={handleSelectedIdsChange}
onRowClick={handleRowClick}
/>
</div>
{/if}
</Card.Content>
@@ -201,15 +433,133 @@
</div>
</div>
<!-- Footer fijo con botones de acción -->
<div
id="digitalizacion-list-footer"
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div
role="toolbar"
aria-label="Acciones de digitalización"
data-digitalizacion-footer-toolbar
class="flex w-full items-center justify-end gap-2"
onkeydown={handleFooterToolbarKeydown}
>
<Button variant="outline" size="sm" data-footer-action="nuevo" onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.new']()}
</Button>
<div class="h-6 w-px bg-border"></div>
<Button
variant="outline"
size="sm"
data-footer-action="digitalizar"
disabled={selectedIds.length !== 1}
onclick={handleDigitalizarSelected}
>
<FileCheck2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_digitalizar']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="acuse"
disabled={selectedIds.length !== 1 || !canOpenAcuse}
onclick={handleAcuseSelected}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="envio-xml"
disabled={selectedIds.length !== 1 || !canDownloadEnvioXml}
onclick={() => handleDownloadSelected('envio-xml', 'envio', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_envio_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="respuesta-xml"
disabled={selectedIds.length !== 1 || !canDownloadRespuestaXml}
onclick={() => handleDownloadSelected('respuesta-xml', 'respuesta', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_respuesta_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="consulta-envio-xml"
disabled={selectedIds.length !== 1 || !canDownloadConsultaEnvioXml}
onclick={() => handleDownloadSelected('consulta-envio-xml', 'consulta_envio', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_envio_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="consulta-respuesta-xml"
disabled={selectedIds.length !== 1 || !canDownloadConsultaRespuestaXml}
onclick={() => handleDownloadSelected('consulta-respuesta-xml', 'consulta_respuesta', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_respuesta_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="zip"
disabled={selectedIds.length !== 1 || !canDownloadZip}
onclick={handleDownloadZip}
>
<FolderArchive class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_download_zip']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="editar"
disabled={selectedIds.length !== 1}
onclick={handleEditSelected}
>
<Pencil class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_edit']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="eliminar"
disabled={selectedIds.length === 0}
onclick={handleFooterDelete}
>
<Trash2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_delete']()}
</Button>
</div>
</div>
</div>
<!-- Dialogs -->
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
{#if selectedItem && digitalizarDialogOpen}
<DigitalizarDialog
bind:open={digitalizarDialogOpen}
item={selectedItem}
onSuccess={handleDigitalizarSuccess}
/>
{#if editingItem}
<CreateEditDialog bind:open={editDialogOpen} item={editingItem} onSuccess={loadData} />
{/if}
{#if progressDialogOpen && currentTaskId}
@@ -217,6 +567,8 @@
bind:open={progressDialogOpen}
taskId={currentTaskId}
nombreArchivo={currentNombreArchivo}
recordId={digitalizarItem?.id}
companyId={companyStore.activeCompany?.id}
onComplete={handleProgressComplete}
onCancel={() => { progressDialogOpen = false; loadData(); }}
/>

View File

@@ -0,0 +1,88 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
items: [],
total: 0,
page: 1,
page_size: 50
};
}
try {
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const search = url.searchParams.get('search') || '';
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId ? parseInt(cookieCompanyId) : parentData.companies?.[0]?.id;
if (!companyId) {
return {
error: 'No se encontró una compañía seleccionada',
items: [],
total: 0,
page,
page_size: pageSize
};
}
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString()
});
if (search) {
params.append('search', search);
}
const response = await authenticatedFetch(
`v1/a76/document-types-digitization/?${params.toString()}`,
{},
cookies,
fetch
);
if (!response.ok) {
const errorText = await response.text();
console.error('📊 [Document Types Digitization] API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
return {
error: `Error ${response.status}: ${response.statusText}`,
items: [],
total: 0,
page,
page_size: pageSize
};
}
const data = await response.json();
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
page_size: data.page_size || pageSize,
error: null
};
} catch (error) {
console.error('📊 [Document Types Digitization] Load error:', error);
return {
error: 'Error loading data',
items: [],
total: 0,
page: 1,
page_size: 50
};
}
};

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { RefreshCw } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
import {
documentTypesDigitizationApi,
type DocumentTypeDigitization
} from '$lib/api/dashboard/reference_data/document_types_digitization';
import DataTable from '$lib/components/dashboard/reference_data/invoice_types/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/reference_data/document_types_digitization/columns';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
onMount(() => {
if (browser) {
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
}
});
let allItems = $state<DocumentTypeDigitization[]>(data.items || []);
let currentPage = $state(data.page || 1);
let pageSize = $state(data.page_size || 50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let error = $state<string | null>(data.error || null);
let searchQuery = $state($page.url.searchParams.get('search') || '');
let timeout: ReturnType<typeof setTimeout>;
let hasMore = $derived(allItems.length < totalItems);
function getActiveCompanyId(): number | null {
const fromStore = companyStore.activeCompany?.id;
if (fromStore) return fromStore;
if (!browser) return null;
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith('active_company_id='))
?.split('=')[1];
if (!cookie) return null;
const parsed = Number(cookie);
return Number.isFinite(parsed) ? parsed : null;
}
function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(async () => {
loading = true;
error = null;
try {
const companyId = getActiveCompanyId();
if (!companyId) {
error = 'No hay empresa activa seleccionada';
return;
}
const response = await documentTypesDigitizationApi.list(1, pageSize, companyId, searchQuery);
if (!response.error && response.data) {
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (err) {
error = 'Error aplicando filtros';
console.error('Error applying filters:', err);
} finally {
loading = false;
}
const url = new URL($page.url);
if (searchQuery) url.searchParams.set('search', searchQuery);
else url.searchParams.delete('search');
history.replaceState(history.state, '', url);
}, 500);
}
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = null;
try {
const companyId = getActiveCompanyId();
if (!companyId) {
error = 'No hay empresa activa seleccionada';
return;
}
const response = await documentTypesDigitizationApi.list(
currentPage + 1,
pageSize,
companyId,
searchQuery
);
if (response.error) {
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => window.location.reload(), 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
allItems = [...allItems, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} catch (err) {
error = 'Error cargando más datos';
console.error('Error loading more document types:', err);
} finally {
loading = false;
}
}
function reloadData() {
window.location.reload();
}
useShortcuts('Tipos de documento para digitalización', [
{ key: 'Alt+Shift+R', description: 'Actualizar Lista', action: reloadData }
]);
const columns = createColumns();
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Tipos de documento para digitalización</h1>
<p class="text-muted-foreground">
Consulta el catálogo fijo de solo lectura utilizado por digitalización y pedimentos.
</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
</div>
{#if error}
<div class="flex-none rounded-md border border-destructive px-4 py-3 text-sm text-destructive">
{error}
</div>
{/if}
<Card.Root class="border bg-background flex min-h-0 flex-1 flex-col">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado de tipos de documento</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input
placeholder="Buscar por código o descripción"
bind:value={searchQuery}
oninput={handleSearch}
class="h-9 w-56 bg-card lg:w-72"
/>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 p-0">
<div class="rounded-md border bg-background overflow-hidden flex min-h-0 flex-1 flex-col">
<DataTable data={allItems} {columns} {loading} {hasMore} {loadMore} />
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">Mostrando {allItems.length} de {totalItems} registros</div>
</div>