Merge pull request 'feature/ventana-de-actualizacion-desactualizacion' (#306) from feature/ventana-de-actualizacion-desactualizacion into development

Reviewed-on: ADUANASOFT/anexo76#306
This commit is contained in:
2026-04-23 22:22:06 +00:00
2 changed files with 240 additions and 73 deletions

View File

@@ -2,9 +2,9 @@
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';
import { friendlyApiErrorParts, humanizeLineReferences, type ApiResponse } from '$lib/api';
export let open = false;
export let taskId: string | null = null;
@@ -20,24 +20,42 @@
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
type DialogStatus = 'idle' | 'running' | 'success' | 'error' | 'validation_error';
let progress = 0;
let statusMessage = 'Iniciando...';
let pollingInterval: any = null;
let isComplete = false;
let hasError = false;
let dialogStatus: DialogStatus = 'idle';
let lastResult: any = null;
let externalBody: any = null;
let externalErrors: any[] | null = null;
let errorTitle = 'Ocurrió un error';
let errorDescription = 'No se pudo completar la acción.';
let errorList: string[] = [];
$: isRunning = dialogStatus === 'running';
$: isSuccess = dialogStatus === 'success';
$: isError = dialogStatus === 'error' || dialogStatus === 'validation_error';
$: completedStepIndex =
steps.length === 0
? -1
: Math.max(
steps.findLastIndex((step) => progress >= step.percent),
isSuccess ? steps.length - 1 : -1
);
$: activeStepIndex =
steps.length === 0
? -1
: Math.min(
steps.findIndex((step) => progress < step.percent) === -1
? steps.length - 1
: steps.findIndex((step) => progress < step.percent),
steps.length - 1
);
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
$: if (open && taskId) {
progress = 0;
statusMessage = 'Iniciando...';
isComplete = false;
hasError = false;
lastResult = null;
externalBody = null;
externalErrors = null;
resetState();
startPolling();
} else if (!open) {
stopPolling();
@@ -54,16 +72,101 @@
}
}
function resetState() {
progress = 0;
statusMessage = 'Iniciando...';
dialogStatus = 'running';
lastResult = null;
externalBody = null;
externalErrors = null;
errorTitle = 'Ocurrió un error';
errorDescription = 'No se pudo completar la acción.';
errorList = [];
}
function normalizeErrorText(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback;
const normalized = humanizeLineReferences(value.trim());
return normalized || fallback;
}
function buildErrorPresentation(
source?: Partial<ApiResponse> | null,
result?: any
): { status: DialogStatus; title: string; description: string; list: string[] } {
const status: DialogStatus =
result?.status === 'validation_error' ? 'validation_error' : 'error';
const validationErrors = source?.validationErrors || result?.validationErrors;
if (validationErrors?.length) {
const { title, description } = friendlyApiErrorParts({
status: source?.status ?? 400,
error: source?.error || result?.message || 'Error de validación',
validationErrors
});
return {
status,
title,
description,
list: [] as string[]
};
}
const resultErrors = Array.isArray(result?.errors)
? result.errors
.map((entry: any) => normalizeErrorText(entry?.message || String(entry), 'Error de validación'))
.filter(Boolean)
: [];
if (resultErrors.length > 0) {
return {
status,
title:
status === 'validation_error'
? 'Se encontraron errores de validación'
: 'No se pudo completar la acción',
description:
normalizeErrorText(result?.message, resultErrors[0] || 'Revisa los datos e intenta de nuevo.'),
list: resultErrors
};
}
const fallbackDescription = normalizeErrorText(
source?.error || result?.message,
status === 'validation_error'
? 'Se encontraron errores de validación. Revisa los datos e intenta de nuevo.'
: 'No se pudo completar la acción. Intenta de nuevo.'
);
return {
status,
title:
status === 'validation_error'
? 'Se encontraron errores de validación'
: 'No se pudo completar la acción',
description: fallbackDescription,
list: [] as string[]
};
}
function setErrorState(source?: Partial<ApiResponse> | null, result?: any) {
const presentation = buildErrorPresentation(source, result);
lastResult = result ?? lastResult;
dialogStatus = presentation.status;
errorTitle = presentation.title;
errorDescription = presentation.description;
errorList = presentation.list;
statusMessage = presentation.description;
stopPolling();
}
async function pollOnce() {
if (!taskId) return;
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const raw = await apiCall(taskId);
const response = raw?.data !== undefined ? raw.data : raw;
if (raw?.error) {
hasError = true;
statusMessage = `Error: ${raw.error}`;
stopPolling();
toast.error(raw.error);
setErrorState(raw, response);
return;
}
if (response?.state === 'PROCESSING' && response.info) {
@@ -71,39 +174,48 @@
statusMessage = response.info.status || 'Procesando...';
} else if (response?.state === 'SUCCESS') {
const result = response.result;
stopPolling();
lastResult = result;
if (result?.status === 'validation_error' || result?.status === 'error') {
// Quedarse abierto, marcar error y dejar que onComplete maneje los mensajes
hasError = true;
isComplete = true;
setErrorState(
{
status: response?.status ?? 400,
error: result?.message,
validationErrors: result?.validationErrors
},
result
);
onComplete(result);
} else {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
dialogStatus = 'success';
stopPolling();
onComplete(result);
}
} else if (response?.state === 'FAILURE') {
hasError = true;
const errMsg = response.result ? String(response.result) : 'Error desconocido';
statusMessage = `Error: ${errMsg}`;
stopPolling();
toast.error(`Falló: ${errMsg}`);
setErrorState({ status: response?.status ?? 500, error: errMsg }, response?.result);
}
}
async function startPolling() {
stopPolling(); // Asegurar limpieza previa
await pollOnce(); // Primer poll inmediato para mostrar progreso sin esperar 1s
try {
await pollOnce(); // Primer poll inmediato para mostrar progreso sin esperar 1s
} catch (error) {
console.error('Error polling task status:', error);
setErrorState({ status: 500, error: error instanceof Error ? error.message : String(error) });
return;
}
pollingInterval = setInterval(async () => {
if (isComplete || hasError) return;
if (!isRunning) return;
try {
await pollOnce();
} catch (error) {
console.error('Error polling task status:', error);
setErrorState({ status: 500, error: error instanceof Error ? error.message : String(error) });
}
}, 1000);
}
@@ -128,11 +240,17 @@
<!-- Lista de pasos con progreso -->
<div class="space-y-2">
{#each steps as step, i}
{@const isDone = progress >= step.percent}
{@const isCurrent = !isDone && (i === 0 || progress >= steps[i - 1]?.percent)}
{@const isDone = i < activeStepIndex || (isSuccess && progress >= step.percent)}
{@const isReached = i <= completedStepIndex}
{@const isCurrent = isRunning && i === activeStepIndex}
{@const isFailed = isError && i === activeStepIndex}
<div
class="flex items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors {isDone
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'
: isFailed
? 'border-destructive/40 bg-destructive/5'
: isReached
? 'border-primary/30 bg-primary/5'
: isCurrent
? 'border-primary/50 bg-primary/5'
: 'border-border/50 bg-muted/30 opacity-60'}"
@@ -140,12 +258,24 @@
<span class="flex-shrink-0 w-6 text-center font-medium text-muted-foreground">
{i + 1}.
</span>
<span class="flex-1 {isDone ? 'text-green-700 dark:text-green-400' : ''}">
<span
class="flex-1 {isDone
? 'text-green-700 dark:text-green-400'
: isFailed
? 'text-destructive'
: isReached
? 'text-primary'
: ''}"
>
{step.label}
</span>
<span class="flex-shrink-0 font-medium tabular-nums">
{#if isDone}
<span class="text-green-600 dark:text-green-400">100%</span>
{:else if isFailed}
<span class="text-destructive">{progress}%</span>
{:else if isReached}
<span class="text-primary">100%</span>
{:else if isCurrent}
<span class="text-primary">{progress}%</span>
{:else}
@@ -154,6 +284,10 @@
</span>
{#if isDone}
<CheckCircle2 class="h-4 w-4 flex-shrink-0 text-green-600" />
{:else if isFailed}
<XCircle class="h-4 w-4 flex-shrink-0 text-destructive" />
{:else if isReached}
<CheckCircle2 class="h-4 w-4 flex-shrink-0 text-primary" />
{:else if isCurrent}
<Loader2 class="h-4 w-4 flex-shrink-0 animate-spin text-primary" />
{/if}
@@ -164,27 +298,29 @@
<Progress value={progress} class="h-1.5 w-full" />
</div>
{#if statusMessage}
<div class="mt-1 px-3 text-xs text-muted-foreground">
<div class="mt-1 px-3 text-xs {isError ? 'text-destructive' : 'text-muted-foreground'}">
{statusMessage}
</div>
{/if}
{:else}
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-muted-foreground">{statusMessage}</span>
<span class={isError ? 'text-destructive' : 'text-muted-foreground'}>{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<Progress value={progress} class="h-2 w-full" />
{/if}
<div class="flex h-16 items-center justify-center">
{#if isComplete}
<div
class="flex justify-center {isError
? 'min-h-0 items-start'
: 'min-h-16 items-center'}"
>
{#if isSuccess}
<div class="animate-in fade-in zoom-in flex flex-col items-center text-green-600 duration-300">
<CheckCircle2 size={48} />
<span class="mt-2 text-sm font-medium">
{#if lastResult?.cove_number}
COVE generado
{:else if lastResult?.status === 'validation_error'}
Se encontraron errores de validación
{:else}
{completeMessage}
{/if}
@@ -205,30 +341,40 @@
</span>
{/if}
<!-- Mensaje legible del API externo (errores o info) -->
{#if lastResult?.status === 'validation_error' && externalBody}
<span class="mt-2 text-xs font-medium text-destructive text-center">
{externalBody.mensaje || 'Datos inválidos en el servicio COVE.'}
</span>
{#if externalErrors && externalErrors.length}
<ul class="mt-1 max-h-32 w-full overflow-auto text-[11px] text-destructive list-disc list-inside text-left">
{#each externalErrors as e}
<li>{e.campo}: {e.mensaje}</li>
{/each}
</ul>
{/if}
{:else if lastResult?.message}
{#if lastResult?.message}
<span class="mt-2 text-xs text-muted-foreground text-center whitespace-pre-line">
{lastResult.message}
</span>
{/if}
</div>
{:else if hasError}
{:else if isError}
<div
class="animate-in fade-in zoom-in flex flex-col items-center text-destructive duration-300"
class="animate-in fade-in zoom-in flex w-full flex-col items-center text-destructive duration-300"
>
<XCircle size={48} />
<span class="mt-2 text-sm font-medium">Ocurrió un error</span>
<div class="mt-3 w-full rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-left">
<p class="text-sm font-semibold">{errorTitle}</p>
<p class="mt-1 text-xs whitespace-pre-line">{errorDescription}</p>
{#if errorList.length > 0}
<ul class="mt-3 max-h-32 overflow-auto list-disc space-y-1 pl-4 text-xs">
{#each errorList as item}
<li>{item}</li>
{/each}
</ul>
{/if}
{#if dialogStatus === 'validation_error' && externalBody}
<p class="mt-3 text-xs font-medium">
{externalBody.mensaje || 'Datos inválidos en el servicio COVE.'}
</p>
{#if externalErrors && externalErrors.length}
<ul class="mt-2 max-h-32 overflow-auto list-disc space-y-1 pl-4 text-xs">
{#each externalErrors as e}
<li>{e.campo}: {e.mensaje}</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{:else}
<div class="flex animate-pulse flex-col items-center text-primary">
@@ -239,7 +385,7 @@
</div>
<Dialog.Footer>
{#if isComplete || hasError}
{#if isSuccess || isError}
<Button variant="secondary" onclick={() => (open = false)}>Cerrar</Button>
{/if}
</Dialog.Footer>

View File

@@ -60,6 +60,7 @@
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaFacturas } from '$lib/config/shortcuts/dashboard/invoices/list';
import { m } from '$lib/i18n/messages';
import { humanizeLineReferences } from '$lib/api';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
@@ -452,6 +453,9 @@
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | null>(null);
let progressDialogTitle = $state('Generando documento');
let progressDialogSteps = $state<{ label: string; percent: number }[] | null>(null);
let isProgressErrorOpen = $state(false);
let progressErrorTitle = $state('Ocurrió un error');
let progressErrorDescription = $state('No se pudo completar la acción.');
let coveRecipientsLoading = $state(false);
let coveRecipientsError = $state<string | null>(null);
let coveRecipientEmail = $state('');
@@ -820,7 +824,6 @@
}
function onPdfComplete(result: any) {
// Esta función se llama cuando el diálogo reporta SUCCESS
try {
if (result.status === 'success') {
if (result.content) {
@@ -856,23 +859,9 @@
m.invoice_list_toasts_cove_external_queued_default();
const taskInfo = result.external_task_id ? ` (task_id: ${result.external_task_id})` : '';
toast.success(baseMsg + taskInfo);
} else if (result.status === 'validation_error') {
const errors: any[] = result.errors || [];
const preview = errors
.slice(0, 3)
.map((e: any) => `• ${e.message}`)
.join('\n');
const extra =
errors.length > 3
? m.invoice_list_toasts_validation_extra_more({ count: String(errors.length - 3) })
: '';
toast.error(
m.invoice_list_toasts_validation_error_count({
count: String(errors.length),
preview,
extra
})
);
} else if (result.status === 'validation_error' || result.status === 'error') {
reloadData();
return;
} else if (
typeof result.message === 'string' &&
result.message.includes('Factura COVE iniciada para')
@@ -1138,7 +1127,8 @@
);
if (response.error) {
toast.error(
openProgressErrorDialog(
'No se pudo iniciar el procesamiento de la factura',
m.invoice_list_toasts_process_start_error_prefix({ error: String(response.error) })
);
return;
@@ -1151,7 +1141,10 @@
showProgressDialog = true;
} catch (e) {
console.error('Error al iniciar proceso de factura:', e);
toast.error(m.invoice_list_toasts_process_start_error());
openProgressErrorDialog(
'No se pudo iniciar el procesamiento de la factura',
m.invoice_list_toasts_process_start_error()
);
}
}
@@ -1166,7 +1159,8 @@
);
if (response.error) {
toast.error(
openProgressErrorDialog(
'No se pudo iniciar la desactualización de la factura',
m.invoice_list_toasts_revert_start_error_prefix({ error: String(response.error) })
);
return;
@@ -1179,7 +1173,10 @@
showProgressDialog = true;
} catch (e) {
console.error('Error al iniciar des-actualización de factura:', e);
toast.error(m.invoice_list_toasts_revert_start_error());
openProgressErrorDialog(
'No se pudo iniciar la desactualización de la factura',
m.invoice_list_toasts_revert_start_error()
);
}
}
@@ -1316,6 +1313,12 @@
progressDialogSteps = null;
}
function openProgressErrorDialog(title: string, description: string) {
progressErrorTitle = title;
progressErrorDescription = humanizeLineReferences(description.trim()) || description;
isProgressErrorOpen = true;
}
// --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS ---
const columns = createColumns(handleSuccess);
async function handleModalConfirm(
@@ -1590,6 +1593,24 @@
}
/>
<AlertDialog.Root bind:open={isProgressErrorOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{progressErrorTitle}</AlertDialog.Title>
<AlertDialog.Description>
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive whitespace-pre-line">
{progressErrorDescription}
</div>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Action onclick={() => (isProgressErrorOpen = false)}>
Cerrar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={isRevertConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>