Se introdujo el sistema de consolidados

This commit is contained in:
2026-01-21 09:47:28 -06:00
parent 53f3915de2
commit 3d39360f9c
17 changed files with 1666 additions and 38 deletions

View File

@@ -0,0 +1,35 @@
const BASE_URL = import.meta.env.VITE_API_URL || '';
export const consolidatedReportsApi = {
triggerPdfGeneration: async (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({ company_id: companyId.toString() });
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/consolidados/${invoiceId}/download-async?${params.toString()}`;
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error('Error al iniciar la generación del consolidado');
return await response.json();
},
getTaskStatus: async (taskId: string) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/consolidados/tasks/${taskId}`;
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Error al consultar estado del consolidado');
return await response.json();
}
};

View File

@@ -0,0 +1,146 @@
<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 * as Table from "$lib/components/ui/table";
import { toast } from "svelte-sonner";
import { Search, Loader2, Hash } from "lucide-svelte";
import { getUSTariffFractions, type USTariffFraction } from "$lib/api/dashboard/a76/general_catalogs/us-tariff-fractions";
import { companyStore } from "$lib/stores/company.svelte";
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean,
onSelect: (item: USTariffFraction) => void
} = $props();
// --- ESTADO ---
let items = $state<USTariffFraction[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
// Filtro local
let filteredItems = $derived(
items.filter(i =>
(i.code || "").includes(searchTerm) ||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Cargar datos al abrir
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadFractions();
}
});
async function loadFractions() {
if (!companyStore.activeCompany?.id) {
toast.error("No hay empresa seleccionada");
return;
}
loading = true;
try {
const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id);
if (response.error) {
console.error("Error al cargar fracciones americanas:", response.error);
toast.error(`Error: ${response.error}`);
return;
}
if (response.data?.items) {
items = response.data.items;
loaded = true;
} else {
console.warn("No se encontraron fracciones americanas:", response);
toast.info("No se encontraron fracciones americanas registradas");
}
} catch (e: any) {
console.error("Excepción cargando fracciones americanas:", e);
toast.error(`Error de conexión: ${e.message || e}`);
} finally {
loading = false;
}
}
function handleSelect(item: USTariffFraction) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[800px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Fracción Americana</Dialog.Title>
<Dialog.Description>
Seleccione la fracción arancelaria (HTS) del catálogo.
</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 código o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading}
<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 filteredItems.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron fracciones.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[150px]">Código (HTS)</Table.Head>
<Table.Head>Descripción</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredItems as item}
<Table.Row
class="cursor-pointer hover:bg-accent/50 transition-colors"
onclick={() => handleSelect(item)}
>
<Table.Cell>
<div class="flex items-center gap-2">
<Hash class="h-3 w-3 text-blue-500" />
<span class="font-mono font-bold text-blue-600 dark:text-blue-400">
{item.code}
</span>
</div>
</Table.Cell>
<Table.Cell class="font-medium text-sm">
{item.description || '-'}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
{filteredItems.length} registros encontrados
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -36,6 +36,7 @@
import CurrencySelectorDialog from '$lib/components/dashboard/goods/modales/currency-selector-dialog.svelte';
import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte';
import FractionSelectorDialog from '$lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte';
import USFractionSelectorDialog from '$lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte';
// --- PROPS ---
let { partId = null, formType = 'inv' }: { partId?: number | null, formType?: 'inv' | 'fa' } = $props();
@@ -57,6 +58,7 @@
let showCurrencyModal = $state(false);
let showCountryModal = $state(false);
let showFractionModal = $state(false);
let showUSFractionModal = $state(false);
// Descripciones Visuales
let selectedClientName = $state("");
@@ -222,6 +224,7 @@
selectedCountryName = country.description_es;
}
function handleFractionSelect(item: any) { formData.fraction = item.fraction; }
function handleUSFractionSelect(item: any) { formData.us_fraction = item.code; }
// --- SUBMIT ---
async function handleSubmit() {
@@ -436,7 +439,10 @@
</div>
<div class="md:col-span-6 space-y-2">
<Label for="fa_us_fraction">Fracción Americana</Label>
<Input id="fa_us_fraction" bind:value={formData.us_fraction} maxlength={10} class="font-mono" placeholder="HTS Code"/>
<div class="flex gap-2">
<Input id="fa_us_fraction" bind:value={formData.us_fraction} maxlength={10} class="font-mono cursor-pointer" placeholder="HTS Code" readonly onclick={() => showUSFractionModal = true}/>
<Button variant="outline" size="icon" type="button" onclick={() => showUSFractionModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
</div>
</div>
<div class="md:col-span-6 space-y-2">
<Label for="fa_sector">Sector</Label>
@@ -691,7 +697,10 @@
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 pt-4 border-t">
<div class="md:col-span-6 space-y-2">
<Label for="frac_us">Fracción Americana (HTS)</Label>
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" class="font-mono"/>
<div class="flex gap-2">
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" class="font-mono cursor-pointer" readonly onclick={() => showUSFractionModal = true}/>
<Button variant="outline" size="icon" type="button" onclick={() => showUSFractionModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
</div>
</div>
</div>
@@ -885,6 +894,7 @@
<CurrencySelectorDialog bind:open={showCurrencyModal} onSelect={handleCurrencySelect} />
<CountrySelectorDialog bind:open={showCountryModal} onSelect={handleCountrySelect} />
<FractionSelectorDialog bind:open={showFractionModal} onSelect={handleFractionSelect} />
<USFractionSelectorDialog bind:open={showUSFractionModal} onSelect={handleUSFractionSelect} />
<style>
:global(.required::after) {

View File

@@ -11,6 +11,8 @@
export let onClose: () => void;
export let onComplete: (result: any) => void;
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
let progress = 0;
let statusMessage = "Iniciando...";
let pollingInterval: any = null;
@@ -42,7 +44,8 @@
if (!taskId) return;
try {
const response = await invoicesReportsApi.getTaskStatus(taskId);
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const response = await apiCall(taskId);
if (response.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
@@ -118,7 +121,7 @@
<Dialog.Footer>
{#if hasError}
<Button variant="secondary" on:click={onClose}>Cerrar</Button>
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>

View File

@@ -40,15 +40,15 @@ class CompanyStore {
if (preloadedCompanies && preloadedCompanies.length > 0) {
// Detectar si el tenant ha cambiado
const newTenantId = preloadedCompanies[0].tenant_id;
// Si el tenant cambió, limpiar el store primero
if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) {
this.clear();
}
this._currentTenantId = newTenantId;
this._companies = preloadedCompanies;
// Si hay compañías y no hay una activa, seleccionar la primera o la guardada
if (this._companies.length > 0 && !this._activeCompany) {
// Intentar restaurar la compañía guardada
@@ -67,13 +67,13 @@ class CompanyStore {
}
return;
}
// Si no hay datos pre-cargados, hacer fetch (fallback)
// Solo en el navegador, nunca durante SSR
if (!browser) {
return;
}
this._loading = true;
try {
const response = await fetch('/api/v1/a76/company/my-companies', {
@@ -81,22 +81,22 @@ class CompanyStore {
});
if (response.ok) {
const newCompanies = await response.json();
// Detectar si el tenant ha cambiado
if (newCompanies.length > 0) {
const newTenantId = newCompanies[0].tenant_id;
// Si el tenant cambió, limpiar el store primero
if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) {
this.clear();
}
this._currentTenantId = newTenantId;
}
this._companies = newCompanies;
// Si hay compañías y no hay una activa, seleccionar la primera
if (this._companies.length > 0 && !this._activeCompany) {
this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
@@ -123,23 +123,23 @@ class CompanyStore {
setActiveCompany(company: Company, silent: boolean = false) {
const previousCompanyId = this._activeCompany?.id;
this._activeCompany = company;
// Guardar en localStorage para persistencia
if (typeof window !== 'undefined') {
localStorage.setItem('activeCompanyId', company.id.toString());
}
// Guardar en cookie para acceso desde el servidor (SSR)
if (typeof document !== 'undefined') {
document.cookie = `active_company_id=${company.id}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
}
// Despachar evento personalizado solo si:
// 1. No es silent (no es inicialización)
// 2. Y realmente cambió la compañía (el ID es diferente)
if (!silent && typeof window !== 'undefined' && previousCompanyId !== company.id) {
window.dispatchEvent(new CustomEvent('companyChanged', {
detail: { companyId: company.id }
window.dispatchEvent(new CustomEvent('companyChanged', {
detail: { companyId: company.id }
}));
}
}
@@ -167,12 +167,12 @@ class CompanyStore {
this._companies = [];
this._loading = false;
this._currentTenantId = null;
// Limpiar localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem('activeCompanyId');
}
// Limpiar cookie
if (typeof document !== 'undefined') {
document.cookie = 'active_company_id=; path=/; max-age=0';

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/invoices';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated';
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
import * as Card from '$lib/components/ui/card';
@@ -12,7 +13,7 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, RefreshCw, FileDown, RotateCcw } from 'lucide-svelte';
import { Plus, RefreshCw, FileText, RotateCcw, Boxes } from 'lucide-svelte';
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
import { toast } from "svelte-sonner";
@@ -334,6 +335,7 @@
// Estado para el diálogo de progreso
let showProgressDialog = $state(false);
let currentTaskId = $state<string | null>(null);
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | null>(null);
// Utilidad para convertir Base64 a Blob
function base64ToBlob(base64: string, type: string) {
@@ -361,6 +363,7 @@
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = invoicesReportsApi.getTaskStatus;
showProgressDialog = true;
} catch (error) {
@@ -369,6 +372,30 @@
}
}
async function handleDownloadConsolidated(invoice: any) {
if (!companyStore.activeCompany) {
toast.error("No hay empresa seleccionada");
return;
}
try {
// 1. Trigger: Iniciar la tarea en Celery (Consolidado)
const { task_id } = await consolidatedReportsApi.triggerPdfGeneration(
invoice.id,
companyStore.activeCompany.id
);
// 2. Abrir diálogo de progreso
currentTaskId = task_id;
currentStatusFunction = consolidatedReportsApi.getTaskStatus;
showProgressDialog = true;
} catch (error) {
console.error(error);
toast.error("No se pudo iniciar la descarga del consolidado");
}
}
function onPdfComplete(result: any) {
// Esta función se llama cuando el diálogo reporta SUCCESS
try {
@@ -454,6 +481,7 @@
function closeProgressDialog() {
showProgressDialog = false;
currentTaskId = null;
currentStatusFunction = null;
}
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
@@ -579,6 +607,7 @@
<PdfProgressDialog
bind:open={showProgressDialog}
taskId={currentTaskId}
getStatus={currentStatusFunction}
onComplete={onPdfComplete}
onClose={closeProgressDialog}
/>
@@ -597,8 +626,12 @@
Desactualizar
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadPdf(selectedInvoice)} disabled={!selectedInvoice}>
<FileDown class="h-4 w-4 mr-2" />
Descargar PDF
<FileText class="h-4 w-4 mr-2" />
Factura
</Button>
<Button variant="outline" size="sm" onclick={() => selectedInvoice && handleDownloadConsolidated(selectedInvoice)} disabled={!selectedInvoice}>
<Boxes class="h-4 w-4 mr-2" />
Consolidado
</Button>
</div>
</div>