Se mejoro el disenio de partes, se genero la informacion mas precisa en los reportes y se carga el logo en las instanacias de las empresas

This commit is contained in:
2026-01-15 13:20:34 -06:00
parent ae1d7f0c58
commit 0a02a3e912
44 changed files with 1536 additions and 515 deletions

View File

@@ -24,9 +24,10 @@
// Filtro reactivo local
let filteredClients = $derived(
clients.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.client_or_provider === 'client' || c.client_or_provider === 'both') &&
(c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.id.toString().includes(searchTerm)
c.id.toString().includes(searchTerm))
)
);
@@ -42,10 +43,8 @@
loading = true;
try {
// Petición a la API
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, {
type: 'client'
});
// Petición a la API - Traer todos para filtrar localmente
const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000);
// Normalización de respuesta
const responseData = (res as any).data || res;

View File

@@ -5,6 +5,7 @@
import * as Table from "$lib/components/ui/table";
import { Search, Loader2, Globe } from "lucide-svelte";
import { countriesApi, type Country } from "$lib/api/dashboard/refrence_data/countries";
import { toast } from "svelte-sonner";
// --- PROPS ---
let {
@@ -33,6 +34,7 @@
// Cargar datos al abrir
$effect(() => {
console.log("CountrySelectorDialog: open changed", open);
if (open) {
loadCountries();
}
@@ -40,10 +42,17 @@
async function loadCountries() {
loading = true;
console.log("Cargando países...");
console.log("CountrySelectorDialog: loading countries...");
try {
const response = await countriesApi.list(1, 300);
// FIX: Reducir tamaño de página para evitar timeouts y manejo de errores
const response = await countriesApi.list(1, 100);
console.log("Respuesta países FULL:", response);
if (response.error) {
console.error("Error API:", response.error);
toast.error(`Error al cargar países: ${response.error}`);
return;
}
// Caso 1: Estructura esperada { data: { items: [...] } }
if (response.data?.items && Array.isArray(response.data.items)) {
@@ -69,16 +78,19 @@
loaded = true;
} else {
console.warn("Estructura de datos no reconocida en countriesApi.list:", response.data);
toast.error("Formato de datos de países no reconocido");
}
}
else {
console.warn("No se encontraron países o formato incorrecto:", response);
toast.error("No se encontraron países");
}
console.log(`Países cargados: ${items.length}`);
} catch (e) {
console.error("Error cargando países:", e);
} catch (e: any) {
console.error("Error cargando países (excepción):", e);
toast.error(`Excepción al cargar países: ${e.message || e}`);
} finally {
loading = false;
}

View File

@@ -3,9 +3,9 @@
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, DollarSign } from "lucide-svelte";
import { getMultiCurrencyTypes, type MultiCurrencyType } from "$lib/api/dashboard/a76/general_catalogs/multi-currency-types";
import { companyStore } from "$lib/stores/company.svelte";
import { currencyTypesApi, type CurrencyType } from "$lib/api/dashboard/refrence_data/currency_types";
// --- PROPS ---
let {
@@ -13,11 +13,11 @@
onSelect
}: {
open: boolean,
onSelect: (item: MultiCurrencyType) => void
onSelect: (item: CurrencyType) => void
} = $props();
// --- ESTADO ---
let items = $state<MultiCurrencyType[]>([]);
let items = $state<CurrencyType[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
@@ -25,39 +25,50 @@
// Filtro local
let filteredItems = $derived(
items.filter(i =>
(i.currency_type_code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.country_key || "").toLowerCase().includes(searchTerm.toLowerCase())
(i.code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.currency_name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.country_description || "").toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Cargar datos al abrir
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
if (open && !loaded) {
loadCurrencies();
}
});
async function loadCurrencies() {
if (!companyStore.activeCompany?.id) return;
loading = true;
console.log("CurrencySelectorDialog: loading currencies (public)...");
try {
const response = await getMultiCurrencyTypes(companyStore.activeCompany.id, 1, 100);
// FIX: Usar API pública, sin company_id
const response = await currencyTypesApi.list(1, 100);
console.log("Respuesta Monedas Public FULL:", response);
if (response?.items) {
items = response.items;
loaded = true;
} else {
console.warn("No se encontraron monedas:", response);
if (response.error) {
console.error("CurrencySelectorDialog Error:", response.error);
toast.error(`Error al cargar monedas: ${response.error}`);
return;
}
} catch (e) {
if (response.data?.items) {
items = response.data.items;
loaded = true;
console.log("CurrencySelectorDialog: loaded items", items.length);
} else {
console.warn("No se encontraron monedas (public):", response);
toast.error("No se encontraron monedas");
}
} catch (e: any) {
console.error("Error cargando monedas:", e);
toast.error(`Excepción al cargar monedas: ${e.message || e}`);
} finally {
loading = false;
}
}
function handleSelect(item: MultiCurrencyType) {
function handleSelect(item: CurrencyType) {
if (onSelect) onSelect(item);
open = false;
}
@@ -68,7 +79,7 @@
<Dialog.Header>
<Dialog.Title>Seleccionar Moneda</Dialog.Title>
<Dialog.Description>
Seleccione el tipo de moneda del catálogo.
Seleccione el tipo de moneda del catálogo público.
</Dialog.Description>
</Dialog.Header>
@@ -76,7 +87,7 @@
<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 país..."
placeholder="Buscar por código, nombre o país..."
class="pl-9"
bind:value={searchTerm}
/>
@@ -96,9 +107,9 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[120px]">Código</Table.Head>
<Table.Head class="w-[100px]">País</Table.Head>
<Table.Head class="text-right">Factor Conversión</Table.Head>
<Table.Head class="w-[100px]">Código</Table.Head>
<Table.Head>Moneda</Table.Head>
<Table.Head>País</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -111,15 +122,15 @@
<div class="flex items-center gap-2">
<DollarSign class="h-3 w-3 text-green-500" />
<span class="font-mono font-bold text-primary">
{item.currency_type_code}
{item.code}
</span>
</div>
</Table.Cell>
<Table.Cell class="font-medium">
{item.country_key || '-'}
{item.currency_name}
</Table.Cell>
<Table.Cell class="text-right font-mono text-sm">
{item.conversion_factor?.toFixed(4) || '-'}
<Table.Cell class="text-sm text-muted-foreground">
{item.country_description || '-'}
</Table.Cell>
</Table.Row>
{/each}

View File

@@ -3,8 +3,9 @@
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 { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes";
import { getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions";
import { companyStore } from "$lib/stores/company.svelte";
// --- PROPS ---
@@ -13,32 +14,23 @@
onSelect
}: {
open: boolean,
onSelect: (item: { fraction: string; description: string; class_code: string }) => void
onSelect: (item: TariffFraction) => void
} = $props();
// --- ESTADO ---
let classes = $state<A76Class[]>([]);
let items = $state<TariffFraction[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
// Extraer fracciones únicas
let uniqueFractions = $derived(
Array.from(new Set(classes.map(c => c.fraction)))
.filter(f => f && f.trim())
.map(fraction => {
const cls = classes.find(c => c.fraction === fraction);
return {
fraction,
description: cls?.description_es || '',
class_code: cls?.class_code || ''
};
})
.filter(item =>
item.fraction.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.class_code.toLowerCase().includes(searchTerm.toLowerCase())
)
// Filtro local
let filteredItems = $derived(
items.filter(i =>
(i.fraction || "").includes(searchTerm) ||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.nico || "").includes(searchTerm) ||
(i.code || "").toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Cargar datos al abrir
@@ -49,41 +41,48 @@
});
async function loadFractions() {
if (!companyStore.activeCompany?.id) return;
if (!companyStore.activeCompany?.id) {
toast.error("No hay empresa seleccionada");
return;
}
loading = true;
try {
const response = await classesApi.list({
company_id: companyStore.activeCompany.id,
page: 1,
page_size: 1000
});
const response = await getTariffFractions(1, 1000, companyStore.activeCompany.id);
if (response.error) {
console.error("Error al cargar fracciones:", response.error);
toast.error(`Error: ${response.error}`);
return;
}
if (response.data?.items) {
classes = response.data.items;
items = response.data.items;
loaded = true;
} else {
console.warn("No se encontraron clases:", response);
console.warn("No se encontraron fracciones:", response);
toast.info("No se encontraron fracciones registradas");
}
} catch (e) {
console.error("Error cargando fracciones:", e);
} catch (e: any) {
console.error("Excepción cargando fracciones:", e);
toast.error(`Error de conexión: ${e.message || e}`);
} finally {
loading = false;
}
}
function handleSelect(item: { fraction: string; description: string; class_code: string }) {
function handleSelect(item: TariffFraction) {
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.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Fracción Arancelaria</Dialog.Title>
<Dialog.Description>
Seleccione la fracción arancelaria del catálogo de clases.
Seleccione la fracción arancelaria del catálogo.
</Dialog.Description>
</Dialog.Header>
@@ -91,7 +90,7 @@
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por fracción, clase o descripción..."
placeholder="Buscar por fracción, NICO o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
@@ -103,7 +102,7 @@
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if uniqueFractions.length === 0}
{: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>
@@ -111,17 +110,21 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[150px]">Fracción</Table.Head>
<Table.Head class="w-[100px]">Código</Table.Head>
<Table.Head class="w-[120px]">Fracción</Table.Head>
<Table.Head class="w-[80px]">NICO</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="w-[120px]">Clase</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each uniqueFractions as item}
{#each filteredItems as item}
<Table.Row
class="cursor-pointer hover:bg-accent/50 transition-colors"
onclick={() => handleSelect(item)}
>
<Table.Cell class="font-mono text-xs text-muted-foreground">
{item.code}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Hash class="h-3 w-3 text-orange-500" />
@@ -130,14 +133,12 @@
</span>
</div>
</Table.Cell>
<Table.Cell class="font-mono text-sm">
{item.nico || '-'}
</Table.Cell>
<Table.Cell class="font-medium text-sm">
{item.description || '-'}
</Table.Cell>
<Table.Cell>
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
{item.class_code}
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -147,7 +148,7 @@
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
{uniqueFractions.length} fracciones únicas encontradas
{filteredItems.length} registros encontrados
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>

View File

@@ -19,6 +19,7 @@
FileText, Settings, Image as ImageIcon, FolderSearch,
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck, Globe
} from 'lucide-svelte';
import { toast } from "svelte-sonner";
// Stores & APIs
import { companyStore } from '$lib/stores/company.svelte';
@@ -78,7 +79,7 @@
weight_type: 'KG',
unit_cost: 0,
currency_type: '',
currency_key: null,
currency_key: null as string | null,
added_value: 0,
value_added_type: 'USD',
us_fraction: '',
@@ -144,6 +145,10 @@
sector: d.fa_data?.sector || '',
fraction_type: d.fa_data?.fraction_type || ''
};
// Ensure currency_type is mapped correctly if coming from DB (optional, depending on DB values)
if (d.currency_key === 'MXN') formData.currency_type = 'NA';
else if (d.currency_key === 'USD') formData.currency_type = 'EX';
if (d.client_id) await fetchClientName(d.client_id, companyId);
if (d.part_class) await fetchClassDesc(d.part_class, companyId);
if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type);
@@ -151,6 +156,18 @@
} catch (e) { console.error(e); } finally { loading = false; }
}
// --- EFECTOS REACTIVOS ---
$effect(() => {
// Auto-set currency based on type selection
if (formData.currency_type === 'NA') {
formData.currency_key = 'MXN';
selectedCurrencyName = 'MXN';
} else if (formData.currency_type === 'EX') {
formData.currency_key = 'USD';
selectedCurrencyName = 'USD';
}
});
// --- HELPERS VISUALES ---
async function fetchClientName(clientId: number, companyId: number) {
try {
@@ -192,11 +209,17 @@
function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; }
function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; }
function handleCurrencySelect(currency: any) {
formData.currency_type = '';
formData.currency_key = currency.currency_type_code;
selectedCurrencyName = currency.currency_type_code;
formData.currency_type = ''; // Reset type legacy field
// FIX: Usar 'code' de la API pública currency_types
const code = currency.code || currency.currency_type_code;
formData.currency_key = code;
selectedCurrencyName = code;
}
function handleCountrySelect(country: any) {
// FIX: Asegurar que se asigna la clave correcta
formData.origin_country = country.m3_key || country.country_key;
selectedCountryName = country.description_es;
}
function handleCountrySelect(country: any) { formData.origin_country = country.m3_key; selectedCountryName = country.description_es; }
function handleFractionSelect(item: any) { formData.fraction = item.fraction; }
// --- SUBMIT ---
@@ -224,15 +247,28 @@
delete commonData.origin_country;
}
console.log("Submitting Part Data:", {
isEdit,
partId,
commonData
});
if (isEdit && partId) {
await partsApi.update(partId, commonData, activeCompanyId);
// TODO: Verify partId is number/string as expected
const res = await partsApi.update(Number(partId), commonData, activeCompanyId);
console.log("Update Response:", res);
if (res.error) throw new Error(res.error);
} else {
const result = await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId);
console.log("Create Response:", result);
if (result.error) { error = result.error; return; }
}
toast.success(isEdit ? "Parte actualizada" : "Parte creada");
goto('/dashboard/goods/parts');
} catch (e: any) {
console.error("Submit Error:", e);
error = e.message || 'Error al guardar';
toast.error(error);
} finally { loading = false; }
}
</script>
@@ -527,7 +563,7 @@
<div class="flex gap-2">
<div class="relative w-full">
<Tag class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input id="part_class_client" bind:value={formData.part_class} maxlength={8} placeholder="Seleccione Clase..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showClassModal = true}/>
<Input id="part_class_client" bind:value={formData.part_class} maxlength={15} placeholder="Clase..." class="pl-9 font-mono"/>
</div>
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
</div>

View File

@@ -14,10 +14,36 @@ function formatDate(date?: string | null): string {
}
export function createColumns(
onSuccess?: () => void,
onDownload?: (invoice: Invoice) => void
onSuccess?: () => void
): ColumnDef<Invoice>[] {
return [
// 0. NUEVA COLUMNA: Checkbox visual (el estado real lo maneja la opacidad)
{
id: "select",
header: ({ table }) => {
return renderSnippet(
createRawSnippet(() => ({
render: () => `<div class="w-4"></div>`
}))
);
},
cell: ({ row }) => {
const isSelected = row.getIsSelected();
const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => {
const { selected } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" class="h-4 w-4" ${selected ? 'checked' : ''} />
</div>`
};
});
return renderSnippet(checkboxSnippet, { selected: isSelected });
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "operation_type",
header: "Operación",
@@ -274,8 +300,7 @@ export function createColumns(
cell: ({ row }) => {
return renderComponent(DataTableActions, {
invoice: row.original,
onSuccess,
onDownload
onSuccess
});
}
}

View File

@@ -3,19 +3,17 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
// 1. Agregamos FileDown a los imports
import { Ellipsis, Eye, Pencil, Trash2, FileDown } from 'lucide-svelte';
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
import DetailsDialog from './details-dialog.svelte';
import DeleteDialog from './delete-dialog.svelte';
interface Props {
invoice: Invoice;
onSuccess?: () => void;
// 2. Definimos la nueva prop (opcional para que no rompa si no se pasa)
onDownload?: (invoice: Invoice) => void;
}
// 3. Desestructuramos onDownload de los props
let { invoice, onSuccess, onDownload }: Props = $props();
let { invoice, onSuccess }: Props = $props();
let showDetails = $state(false);
let showDelete = $state(false);
@@ -44,12 +42,6 @@
Ver Detalles
</DropdownMenu.Item>
{#if onDownload}
<DropdownMenu.Item onclick={() => onDownload(invoice)}>
<FileDown class="mr-2 h-4 w-4" />
Descargar PDF
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onclick={handleEdit}>
<Pencil class="mr-2 h-4 w-4" />

View File

@@ -13,6 +13,9 @@
loading: boolean;
hasMore: boolean;
loadMore: () => void;
// Props para selección
selectedId?: number | null;
onRowClick?: (row: TData) => void;
};
let {
@@ -20,7 +23,9 @@
columns,
loading,
hasMore,
loadMore
loadMore,
selectedId = null,
onRowClick
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -28,7 +33,17 @@
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(), // Usar ID para identificar filas
state: {
get rowSelection() {
// Mapear el ID seleccionado al formato que espera TanStack Table
return selectedId ? { [selectedId]: true } : {};
}
},
enableRowSelection: true,
enableMultiRowSelection: false, // Solo permitir una selección a la vez
// No necesitamos onRowSelectionChange porque controlamos el estado desde fuera
});
let scrollContainer = $state<HTMLDivElement>();
@@ -80,7 +95,11 @@
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && "selected"}>
<Table.Row
data-state={row.getIsSelected() && "selected"}
class="cursor-pointer transition-colors {row.getIsSelected() ? 'bg-gray-300 dark:bg-gray-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => onRowClick && onRowClick(row.original)}
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { FileDown, LoaderCircle } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
export let invoiceId: number;
export let companyId: number;
let processing = false;
async function startDownload() {
if (processing) return;
processing = true;
const toastId = toast.loading('Iniciando generación de PDF...');
try {
const { task_id } = await invoicesReportsApi.triggerPdfGeneration(invoiceId, companyId);
const pollInterval = setInterval(async () => {
try {
const statusData = await invoicesReportsApi.getTaskStatus(task_id);
if (statusData.state === 'SUCCESS') {
clearInterval(pollInterval);
toast.success('Factura generada correctamente', { id: toastId });
const { content, file_name, media_type } = statusData.result;
downloadBase64File(content, media_type, file_name);
processing = false;
} else if (statusData.state === 'FAILURE') {
clearInterval(pollInterval);
throw new Error(statusData.result || 'Error desconocido');
} else if (statusData.state === 'PROCESSING') {
const meta = statusData.result;
if (meta && typeof meta === 'object') {
const current = meta.current || 0;
const total = meta.total || 100;
const progress = Math.round((current / total) * 100);
// Update toast with progress
toast.loading(`Generando PDF: ${progress}%`, {
id: toastId,
description: meta.status || 'Procesando...'
});
}
}
} catch (err: any) {
clearInterval(pollInterval);
handleError(err, toastId);
}
}, 1000);
} catch (err: any) {
handleError(err, toastId);
}
}
function handleError(err: any, toastId: string | number) {
processing = false;
console.error(err);
toast.error('Error al generar PDF: ' + (err.message || 'Error desconocido'), { id: toastId });
}
function downloadBase64File(base64Data: string, contentType: string, fileName: string) {
const linkSource = `data:${contentType};base64,${base64Data}`;
const downloadLink = document.createElement("a");
downloadLink.href = linkSource;
downloadLink.download = fileName;
downloadLink.click();
}
</script>
<Button
variant="outline"
onclick={startDownload}
disabled={processing}
class="w-[100px]"
>
{#if processing}
<LoaderCircle size={16} class="mr-2 animate-spin" />
PDF
{:else}
<FileDown size={16} class="mr-2" />
PDF
{/if}
</Button>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog";
import { Progress } from "$lib/components/ui/progress";
import { invoicesReportsApi } from "$lib/api/dashboard/a76/reports/reports-invoices";
import { toast } from "svelte-sonner";
import { Loader2, CheckCircle2, XCircle, FileDown } from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
export let open = false;
export let taskId: string | null = null;
export let onClose: () => void;
export let onComplete: (result: any) => void;
let progress = 0;
let statusMessage = "Iniciando...";
let pollingInterval: any = null;
let isComplete = false;
let hasError = false;
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
$: if (open && taskId) {
progress = 0;
statusMessage = "Iniciando...";
isComplete = false;
hasError = false;
startPolling();
} else if (!open) {
stopPolling();
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
async function startPolling() {
stopPolling(); // Asegurar limpieza previa
pollingInterval = setInterval(async () => {
if (!taskId) return;
try {
const response = await invoicesReportsApi.getTaskStatus(taskId);
if (response.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
statusMessage = response.info.status || "Procesando...";
}
else if (response.state === 'SUCCESS') {
progress = 100;
statusMessage = "¡Completado!";
isComplete = true;
stopPolling();
// Pequeña pausa para ver el 100%
setTimeout(() => {
onComplete(response.result);
}, 500);
}
else if (response.state === 'FAILURE') {
hasError = true;
statusMessage = "Error al generar el PDF";
stopPolling();
toast.error("Falló la generación del PDF");
}
} catch (error) {
console.error("Error polling task status:", error);
// No detenemos el polling inmediatamente por un error de red transitorio,
// pero podríamos contar intentos fallidos si fuera necesario.
}
}, 1000);
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
stopPolling();
onClose();
}
}
</script>
<Dialog.Root bind:open={open} onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Generando PDF</Dialog.Title>
<Dialog.Description>
Por favor espere mientras se genera su documento.
</Dialog.Description>
</Dialog.Header>
<div class="py-6 flex flex-col gap-6">
<div class="flex items-center justify-between text-sm mb-1">
<span class="text-muted-foreground">{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<Progress value={progress} class="w-full h-2" />
<div class="flex justify-center items-center h-16">
{#if isComplete}
<div class="flex flex-col items-center text-green-600 animate-in fade-in zoom-in duration-300">
<CheckCircle2 size={48} />
<span class="text-sm font-medium mt-2">Listo para descargar</span>
</div>
{:else if hasError}
<div class="flex flex-col items-center text-destructive animate-in fade-in zoom-in duration-300">
<XCircle size={48} />
<span class="text-sm font-medium mt-2">Ocurrió un error</span>
</div>
{:else}
<div class="flex flex-col items-center text-primary animate-pulse">
<FileDown size={48} class="opacity-50" />
</div>
{/if}
</div>
</div>
<Dialog.Footer>
{#if hasError}
<Button variant="secondary" on:click={onClose}>Cerrar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>