feature/ventana-de-actualizacion-desactualizacion
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user