feature/COVE-api-email-selector

This commit is contained in:
2026-04-13 14:12:25 -06:00
parent 07dd69a28a
commit 233da55f40
7 changed files with 389 additions and 25 deletions

View File

@@ -545,15 +545,19 @@ export const invoicesApi = {
}>(`/v1/a76/invoices/revert/${taskId}/status`);
},
generateCove: (invoiceId: number, companyId: number) => {
generateCove: (invoiceId: number, companyId: number, recipientEmail?: string | null) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
const body: Record<string, unknown> = {
company_id: companyId
};
if (recipientEmail) {
body.recipient_email = recipientEmail;
}
return api.post<{ task_id: string }>(
`/v1/a76/factura-cove/invoices/${invoiceId}/cove?${params.toString()}`,
{
company_id: companyId
}
body
);
},

View File

@@ -15,12 +15,17 @@
import { createColumns } from '$lib/components/dashboard/invoices/columns.js';
import * as Card from '$lib/components/ui/card';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { currentUser } from '$lib/auth';
import { companyStore } from '$lib/stores/company.svelte';
import { getCompany } from '$lib/api/dashboard/a76/general_catalogs/company';
import { usersAPI } from '$lib/api/dashboard/users';
import {
Plus,
RefreshCw,
@@ -41,7 +46,8 @@
BadgeCent,
ArrowRightLeft,
Database,
ChevronUp
ChevronUp,
Mail
} from 'lucide-svelte';
import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte';
import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte';
@@ -57,6 +63,13 @@
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
type CoveRecipientOption = {
email: string;
label: string;
description: string;
source: 'company' | 'user';
};
// Estado para filtros
// Nota: Los query parameters invoice_type y operation_type se pueden usar para filtrar
// Ejemplo: /dashboard/invoices?invoice_type=TEM&operation_type=imp
@@ -425,10 +438,189 @@
// Estado para el diálogo de progreso
let showProgressDialog = $state(false);
let isWinsaiiConfirmOpen = $state(false);
let isCoveDialogOpen = $state(false);
let currentTaskId = $state<string | null>(null);
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | null>(null);
let progressDialogTitle = $state('Generando documento');
let progressDialogSteps = $state<{ label: string; percent: number }[] | null>(null);
let coveRecipientsLoading = $state(false);
let coveRecipientsError = $state<string | null>(null);
let coveRecipientEmail = $state('');
let coveRecipientOptions = $state<CoveRecipientOption[]>([]);
let coveRecipientsRequestId = 0;
const currentUserEmail = $derived(($currentUser?.email || '').trim());
const selectedCoveRecipient = $derived(
coveRecipientOptions.find(
(option) => option.email.trim().toLowerCase() === coveRecipientEmail.trim().toLowerCase()
) ?? null
);
function normalizeEmail(email?: string | null) {
return (email || '').trim().toLowerCase();
}
function createRecipientLabel(prefix: string, email: string) {
return `${prefix} - ${email}`;
}
async function loadCoveRecipients() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
coveRecipientsError = null;
coveRecipientOptions = [];
coveRecipientEmail = '';
return;
}
const requestId = ++coveRecipientsRequestId;
coveRecipientsLoading = true;
coveRecipientsError = null;
try {
const [companyResult, usersResult] = await Promise.allSettled([
getCompany(companyId),
usersAPI.list(companyId, { page_size: 100 })
]);
if (requestId !== coveRecipientsRequestId) return;
const recipientMap = new Map<string, CoveRecipientOption>();
const addRecipient = (
email?: string | null,
label?: string,
description?: string,
source: 'company' | 'user' = 'user'
) => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) return;
if (recipientMap.has(normalizedEmail)) return;
recipientMap.set(normalizedEmail, {
email: email!.trim(),
label: label || email!.trim(),
description: description || email!.trim(),
source
});
};
if (companyResult.status === 'fulfilled' && companyResult.value.data) {
const company = companyResult.value.data as Record<string, any>;
addRecipient(
company.vu_email,
'Correo VU de la empresa',
company.name ? `Empresa ${company.name}` : 'Correo de ventanilla única',
'company'
);
addRecipient(
company.main_email,
'Correo principal de la empresa',
company.name ? `Empresa ${company.name}` : 'Correo principal',
'company'
);
addRecipient(
company.ind1_email,
'Correo industrial 1',
company.name ? `Empresa ${company.name}` : 'Correo industrial 1',
'company'
);
addRecipient(
company.ind2_email,
'Correo industrial 2',
company.name ? `Empresa ${company.name}` : 'Correo industrial 2',
'company'
);
}
if (usersResult.status === 'fulfilled' && usersResult.value.users) {
for (const user of usersResult.value.users) {
const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim();
addRecipient(
user.email,
fullName || user.username || user.email,
createRecipientLabel('Usuario de la empresa', user.email),
'user'
);
}
}
if (currentUserEmail) {
addRecipient(
currentUserEmail,
'Mi correo',
createRecipientLabel('Usuario autenticado', currentUserEmail),
'user'
);
}
const recipients = [...recipientMap.values()].sort((left, right) => {
if (left.source !== right.source) {
return left.source === 'company' ? -1 : 1;
}
return left.label.localeCompare(right.label, 'es');
});
const sourceFailures = [companyResult, usersResult].filter(
(result) => result.status === 'rejected'
).length;
coveRecipientOptions = recipients;
const preferredEmail = normalizeEmail(currentUserEmail);
const existingSelection =
recipients.find((option) => normalizeEmail(option.email) === normalizeEmail(coveRecipientEmail)) ||
null;
const preferredSelection =
recipients.find((option) => normalizeEmail(option.email) === preferredEmail) || recipients[0] || null;
if (existingSelection) {
coveRecipientEmail = existingSelection.email;
} else if (preferredSelection) {
coveRecipientEmail = preferredSelection.email;
} else {
coveRecipientEmail = '';
}
if (recipientMap.size === 0 && sourceFailures > 0) {
coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE';
} else if (recipientMap.size === 0) {
coveRecipientsError = 'No hay correos configurados para COVE';
}
} catch (error) {
if (requestId !== coveRecipientsRequestId) return;
console.error('Error cargando correos de COVE:', error);
coveRecipientsError = 'No se pudieron cargar los correos disponibles para COVE';
} finally {
if (requestId === coveRecipientsRequestId) {
coveRecipientsLoading = false;
}
}
}
$effect(() => {
const companyId = companyStore.activeCompany?.id;
const userEmail = currentUserEmail;
if (!companyId) {
coveRecipientsError = null;
coveRecipientOptions = [];
coveRecipientEmail = '';
return;
}
void userEmail;
void loadCoveRecipients();
});
function openCoveDialog() {
if (!selectedInvoice || !companyStore.activeCompany) {
toast.info('Selecciona una factura para generar COVE');
return;
}
void loadCoveRecipients();
isCoveDialogOpen = true;
}
// Utilidad para convertir Base64 a Blob
function base64ToBlob(base64: string, type: string) {
@@ -808,12 +1000,18 @@
}
}
async function handleGenerateCove() {
async function handleGenerateCove(recipientEmail = coveRecipientEmail) {
if (!selectedInvoice || !companyStore.activeCompany) {
toast.info('Selecciona una factura para generar COVE');
return;
}
const selectedRecipientEmail = normalizeEmail(recipientEmail);
if (!selectedRecipientEmail) {
toast.error('Selecciona un correo para enviar el COVE');
return;
}
const companyId = companyStore.activeCompany.id;
// Paso 1: Checar elegibilidad antes de disparar la tarea
@@ -840,13 +1038,19 @@
// Paso 2: Disparar tarea Celery de COVE
try {
const response = await invoicesApi.generateCove(selectedInvoice.id, companyId);
const response = await invoicesApi.generateCove(
selectedInvoice.id,
companyId,
selectedRecipientEmail
);
if (response.error) {
toast.error(`Error al iniciar generación de COVE: ${response.error}`);
return;
}
isCoveDialogOpen = false;
currentTaskId = response.data!.task_id;
currentStatusFunction = invoicesApi.getCoveStatus;
progressDialogTitle = 'Validando datos para COVE';
@@ -1065,6 +1269,101 @@
<div class="h-20"></div>
<Dialog.Root bind:open={isCoveDialogOpen}>
<Dialog.Content class="sm:!max-w-2xl">
<Dialog.Header>
<Dialog.Title>Generar COVE</Dialog.Title>
<Dialog.Description>
Selecciona el correo destinatario para la factura
<strong>{selectedInvoice?.invoice_number}</strong>.
</Dialog.Description>
</Dialog.Header>
<Card.Root class="border bg-card/90 shadow-none">
<Card.Content class="space-y-4 p-4 sm:p-5">
<div class="space-y-2">
<Label for="cove-recipient">Correo destinatario</Label>
<Select.Root bind:value={coveRecipientEmail}>
<Select.Trigger
id="cove-recipient"
class="h-auto min-h-14 w-full bg-background px-3 py-2"
disabled={coveRecipientsLoading || coveRecipientOptions.length === 0}
>
{#snippet children()}
<div class="flex min-w-0 flex-1 items-center gap-3 text-left">
<div class="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted">
<Mail class="size-4 text-muted-foreground" />
</div>
<div class="min-w-0 flex-1">
<div class="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
Destino COVE
</div>
<div class="truncate text-sm font-medium">
{selectedCoveRecipient?.label || 'Selecciona un correo'}
</div>
<div class="truncate text-xs text-muted-foreground">
{selectedCoveRecipient?.email || 'Se enviará al correo del usuario que generó la factura'}
</div>
</div>
</div>
{/snippet}
</Select.Trigger>
<Select.Content searchable searchPlaceholder="Buscar correo...">
{#if coveRecipientsLoading}
<div class="px-2 py-3 text-sm text-muted-foreground">
Cargando correos disponibles...
</div>
{:else if coveRecipientOptions.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">
No hay correos disponibles para COVE.
</div>
{:else}
{#each coveRecipientOptions as recipient}
<Select.Item
value={recipient.email}
label={recipient.label}
searchText={`${recipient.label} ${recipient.email} ${recipient.description}`}
>
{#snippet children({ selected })}
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium">
{recipient.label}
{#if selected}
<span class="ml-2 text-xs text-primary">Seleccionado</span>
{/if}
</span>
<span class="truncate text-xs text-muted-foreground">
{recipient.description}
</span>
</div>
{/snippet}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{#if coveRecipientsError}
<p class="text-sm text-destructive">{coveRecipientsError}</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCoveDialogOpen = false)}>
Cancelar
</Button>
<Button
onclick={() => handleGenerateCove(coveRecipientEmail)}
disabled={coveRecipientsLoading || !coveRecipientEmail}
>
<Mail class="mr-2 h-4 w-4" />
Generar COVE
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<PdfProgressDialog
bind:open={showProgressDialog}
taskId={currentTaskId}
@@ -1132,9 +1431,12 @@
<!-- Capa para cerrar el submenú al hacer click fuera -->
<div
class="fixed inset-0 z-40"
on:click={() => (showVuSubmenu = false)}
on:contextmenu|preventDefault={() => (showVuSubmenu = false)}
/>
onclick={() => (showVuSubmenu = false)}
oncontextmenu={(event) => {
event.preventDefault();
showVuSubmenu = false;
}}
></div>
<!-- Submenú contextual de Interface VU -->
<div
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm shadow-md"
@@ -1143,7 +1445,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('Consulta VU - Próximamente');
}}
@@ -1154,7 +1456,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('Adenda VU - Próximamente');
}}
@@ -1165,9 +1467,9 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
handleGenerateCove();
openCoveDialog();
}}
>
<Files class="mr-2 h-4 w-4" />
@@ -1176,7 +1478,7 @@
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent"
on:click={() => {
onclick={() => {
showVuSubmenu = false;
toast.info('COVE Masivos - Próximamente');
}}
@@ -1291,7 +1593,7 @@
<!-- Interface VU: click izquierdo = COVE, click derecho = submenú -->
<DropdownMenu.Item
class="cursor-pointer"
onclick={() => handleGenerateCove()}
onclick={() => openCoveDialog()}
oncontextmenu={(event) => {
event.preventDefault();
event.stopPropagation();