Tipo de cambio actualizado

This commit is contained in:
2026-02-09 13:39:03 -06:00
parent b76bd71265
commit 99d81e66b9
6 changed files with 1042 additions and 875 deletions

View File

@@ -1,287 +1,327 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { toast } from "svelte-sonner";
import {
createExchangeRate,
updateExchangeRate,
type ExchangeRate,
getDofExchangeRate
} from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
import { companyStore } from "$lib/stores/company.svelte";
import { Scale, BadgeDollarSign, Info, AlertCircle, ArrowRight, CloudDownload } from "lucide-svelte";
import { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import {
createExchangeRate,
updateExchangeRate,
type ExchangeRate,
getDofExchangeRate
} from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
import { companyStore } from '$lib/stores/company.svelte';
import {
Scale,
BadgeDollarSign,
Info,
AlertCircle,
ArrowRight,
CloudDownload
} from 'lucide-svelte';
import { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
let {
open = $bindable(false),
item = null,
onSuccess,
overlayClass = "bg-black/80 backdrop-blur-sm",
initialDate = ""
}: {
open: boolean;
item?: ExchangeRate | null;
onSuccess?: () => void;
overlayClass?: string;
initialDate?: string;
} = $props();
let {
open = $bindable(false),
item = null,
onSuccess,
overlayClass = 'bg-black/80 backdrop-blur-sm',
initialDate = ''
}: {
open: boolean;
item?: ExchangeRate | null;
onSuccess?: () => void;
overlayClass?: string;
initialDate?: string;
} = $props();
const isEdit = $derived(!!item);
const isMissingRateContext = $derived(!isEdit && initialDate);
const title = $derived(
isEdit ? "Editar Tipo de Cambio" :
isMissingRateContext ? "Tipo de Cambio Requerido" : "Nuevo Tipo de Cambio"
);
const isEdit = $derived(!!item);
const isMissingRateContext = $derived(!isEdit && initialDate);
const title = $derived(
isEdit
? 'Editar Tipo de Cambio'
: isMissingRateContext
? 'Tipo de Cambio Requerido'
: 'Nuevo Tipo de Cambio'
);
let formData = $state({
date: '',
value: null as number | null,
local_currency: '',
foreign_currency: ''
});
let formData = $state({
date: '',
value: null as number | null,
local_currency: '',
foreign_currency: ''
});
let loading = $state(false);
let scraping = $state(false);
let error = $state<string | null>(null);
let showConfirmation = $state(false);
let loading = $state(false);
let scraping = $state(false);
let error = $state<string | null>(null);
let showConfirmation = $state(false);
// Cargar datos al abrir
$effect(() => {
if (open) {
if (item && item.date) {
const formattedDate = item.date.includes('T') ? item.date.split('T')[0] : item.date;
formData = {
date: formattedDate,
value: item.value,
local_currency: item.local_currency || '',
foreign_currency: item.foreign_currency || ''
};
} else {
formData = {
date: initialDate || new Date().toISOString().split('T')[0],
value: null,
local_currency: 'MXN',
foreign_currency: 'USD'
};
// Si es modo contexto (falta dato) y tenemos fecha, intentar cargar automáticamente del DOF
if (initialDate && !item) {
// Opcional: Auto-consultar
// fetchFromDof(initialDate);
}
}
error = null;
}
});
// Cargar datos al abrir
$effect(() => {
if (open) {
if (item && item.date) {
const formattedDate = item.date.includes('T') ? item.date.split('T')[0] : item.date;
formData = {
date: formattedDate,
value: item.value,
local_currency: item.local_currency || '',
foreign_currency: item.foreign_currency || ''
};
} else {
formData = {
date: initialDate || new Date().toISOString().split('T')[0],
value: null,
local_currency: 'MXN',
foreign_currency: 'USD'
};
async function fetchFromDof(date: string) {
if (!date) return;
scraping = true;
error = null;
try {
const response = await getDofExchangeRate(date);
// The API returns an ApiResponse object, so we need to access response.data
// response.data contains { success: boolean, value: number, message: string }
if (response.data?.success && response.data?.value) {
formData.value = response.data.value;
toast.success(`Tipo de cambio obtenido del DOF: ${response.data.value}`);
} else {
toast.error(response.data?.message || response.error || 'No se pudo obtener el dato del DOF');
// No bloquear, permitir manual
}
} catch (e) {
console.error(e);
toast.error('Error al consultar el servicio del DOF');
} finally {
scraping = false;
}
}
// Si es modo contexto (falta dato) y tenemos fecha, intentar cargar automáticamente del DOF
if (initialDate && !item) {
// Opcional: Auto-consultar
// fetchFromDof(initialDate);
}
}
error = null;
}
});
function handleSubmit() {
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!formData.date) throw new Error('La fecha es requerida');
if (formData.value === null) throw new Error('El valor es requerido');
showConfirmation = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Error al validar';
}
}
async function fetchFromDof(date: string) {
if (!date) return;
scraping = true;
error = null;
try {
const response = await getDofExchangeRate(date);
// The API returns an ApiResponse object, so we need to access response.data
// response.data contains { success: boolean, value: number, message: string }
if (response.data?.success && response.data?.value) {
formData.value = response.data.value;
toast.success(`Tipo de cambio obtenido del DOF: ${response.data.value}`);
} else {
toast.error(
response.data?.message || response.error || 'No se pudo obtener el dato del DOF'
);
// No bloquear, permitir manual
}
} catch (e) {
console.error(e);
toast.error('Error al consultar el servicio del DOF');
} finally {
scraping = false;
}
}
async function confirmSubmit() {
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
function handleSubmit() {
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!formData.date) throw new Error('La fecha es requerida');
if (formData.value === null) throw new Error('El valor es requerido');
const dataToSend = {
date: formData.date,
value: Number(formData.value),
local_currency: formData.local_currency?.trim().toUpperCase() || null,
foreign_currency: formData.foreign_currency?.trim().toUpperCase() || null
};
showConfirmation = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Error al validar';
}
}
if (isEdit && item) {
await updateExchangeRate(item.id, dataToSend, companyId);
toast.success('Tipo de cambio actualizado correctamente');
} else {
await createExchangeRate(dataToSend, companyId);
toast.success('Tipo de cambio creado correctamente');
}
async function confirmSubmit() {
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
showConfirmation = false;
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar';
showConfirmation = false;
} finally {
loading = false;
}
}
const dataToSend = {
date: formData.date,
value: Number(formData.value),
local_currency: formData.local_currency?.trim().toUpperCase() || null,
foreign_currency: formData.foreign_currency?.trim().toUpperCase() || null
};
if (isEdit && item) {
await updateExchangeRate(item.id, dataToSend, companyId);
toast.success('Tipo de cambio actualizado correctamente');
} else {
await createExchangeRate(dataToSend, companyId);
toast.success('Tipo de cambio creado correctamente');
}
showConfirmation = false;
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar';
showConfirmation = false;
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[9999] {overlayClass}" />
<Dialog.Content
class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] border-0 bg-transparent shadow-2xl p-0 sm:rounded-xl overflow-hidden"
>
<div class="bg-background flex flex-col h-full rounded-xl overflow-hidden border border-border">
{#if isMissingRateContext}
<div class="bg-amber-50 dark:bg-amber-950/40 p-5 flex gap-4 border-b border-amber-100 dark:border-amber-900/50">
<div class="bg-amber-100 dark:bg-amber-900/60 p-2.5 rounded-full h-fit shadow-sm shrink-0">
<AlertCircle class="text-amber-600 dark:text-amber-400" size={24} />
</div>
<div class="space-y-1">
<h3 class="font-semibold text-amber-900 dark:text-amber-100 text-lg leading-tight">
{title}
</h3>
<p class="text-sm text-amber-800/80 dark:text-amber-200/80 leading-relaxed">
Para continuar con el guardado, es necesario registrar el tipo de cambio oficial para esta fecha.
</p>
</div>
</div>
{:else}
<Dialog.Header class="p-6 pb-2">
<Dialog.Title class="flex items-center gap-2 text-xl">
<BadgeDollarSign class="text-primary" />
{title}
</Dialog.Title>
</Dialog.Header>
{/if}
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-[9999] {overlayClass}" />
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="p-6 pt-4 space-y-6">
{#if error}
<div class="rounded-lg bg-destructive/10 border border-destructive/20 p-3 flex gap-3 text-sm text-destructive" transition:fly={{ y: -10 }}>
<AlertCircle size={16} class="mt-0.5 shrink-0" />
<span class="font-medium">{error}</span>
</div>
{/if}
<Dialog.Content
class="fixed top-[50%] left-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] overflow-hidden border-0 bg-transparent p-0 shadow-2xl sm:rounded-xl"
>
<div
class="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-background"
>
{#if isMissingRateContext}
<div
class="flex gap-4 border-b border-amber-100 bg-amber-50 p-5 dark:border-amber-900/50 dark:bg-amber-950/40"
>
<div
class="h-fit shrink-0 rounded-full bg-amber-100 p-2.5 shadow-sm dark:bg-amber-900/60"
>
<AlertCircle class="text-amber-600 dark:text-amber-400" size={24} />
</div>
<div class="space-y-1">
<h3 class="text-lg leading-tight font-semibold text-amber-900 dark:text-amber-100">
{title}
</h3>
<p class="text-sm leading-relaxed text-amber-800/80 dark:text-amber-200/80">
Para continuar con el guardado, es necesario registrar el tipo de cambio oficial
para esta fecha.
</p>
</div>
</div>
{:else}
<Dialog.Header class="p-6 pb-2">
<Dialog.Title class="flex items-center gap-2 text-xl">
<BadgeDollarSign class="text-primary" />
{title}
</Dialog.Title>
</Dialog.Header>
{/if}
<div class="grid gap-5">
<div class="grid gap-2">
<Label for="date" class="text-sm font-medium text-muted-foreground ml-1">Fecha Aplicable</Label>
<div class="relative">
<Input
id="date"
type="date"
bind:value={formData.date}
disabled={loading}
required
class="pl-3 h-11 text-base bg-muted/30 focus:bg-background transition-colors"
/>
{#if isMissingRateContext}
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-600 font-medium bg-amber-100 px-2 py-0.5 rounded-full">
Requerida
</div>
{/if}
</div>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6 p-6 pt-4"
>
{#if error}
<div
class="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive"
transition:fly={{ y: -10 }}
>
<AlertCircle size={16} class="mt-0.5 shrink-0" />
<span class="font-medium">{error}</span>
</div>
{/if}
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="value" class="text-sm font-medium text-muted-foreground ml-1">Tipo de Cambio (MXN/USD)</Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 text-xs text-primary hover:text-primary/80 px-2 -mr-2"
disabled={scraping || loading || !formData.date}
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="animate-spin mr-1.5"></span> Consultando...
{:else}
<CloudDownload size={14} class="mr-1.5" /> Consultar DOF
{/if}
</Button>
</div>
<div class="relative group">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-semibold">$</div>
<Input
id="value"
type="number"
step="0.0001"
bind:value={formData.value}
disabled={loading}
required
placeholder="0.0000"
class="pl-7 h-11 text-lg font-mono tracking-wide focus:ring-2 ring-primary/20 transition-all group-hover:border-primary/50"
autofocus
/>
</div>
<p class="text-[11px] text-muted-foreground text-right px-1">
Ej. 24.1234
</p>
</div>
</div>
<div class="grid gap-5">
<div class="grid gap-2">
<Label for="date" class="ml-1 text-sm font-medium text-muted-foreground"
>Fecha Aplicable</Label
>
<div class="relative">
<Input
id="date"
type="date"
bind:value={formData.date}
disabled={loading}
required
class="h-11 bg-muted/30 pl-3 text-base transition-colors focus:bg-background"
/>
{#if isMissingRateContext}
<div
class="absolute top-1/2 right-3 -translate-y-1/2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-600"
>
Requerida
</div>
{/if}
</div>
</div>
<Dialog.Footer class="grid grid-cols-4 gap-2 pt-2">
<Button type="button" variant="outline" class="w-full text-xs" disabled>
Cargar TC
</Button>
<Button type="button" variant="outline" class="w-full text-xs" disabled>
Ayuda
</Button>
<Button type="button" variant="ghost" onclick={() => open = false} disabled={loading} class="w-full hover:bg-muted/50">
Cancelar
</Button>
<Button type="submit" disabled={loading} class="w-full shadow-sm font-medium">
{#if loading}
<span class="animate-spin mr-2"></span>
{:else}
Ok
{/if}
</Button>
</Dialog.Footer>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
>Tipo de Cambio (MXN/USD)</Label
>
<Button
type="button"
variant="ghost"
size="sm"
class="-mr-2 h-6 px-2 text-xs text-primary hover:text-primary/80"
disabled={scraping || loading || !formData.date}
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="mr-1.5 animate-spin"></span> Consultando...
{:else}
<CloudDownload size={14} class="mr-1.5" /> Consultar DOF
{/if}
</Button>
</div>
<div class="group relative">
<div
class="absolute top-1/2 left-3 -translate-y-1/2 font-semibold text-muted-foreground"
>
$
</div>
<Input
id="value"
type="number"
step="0.0001"
bind:value={formData.value}
disabled={loading}
required
placeholder="0.0000"
class="h-11 pl-7 font-mono text-lg tracking-wide ring-primary/20 transition-all group-hover:border-primary/50 focus:ring-2"
autofocus
/>
</div>
<p class="px-1 text-right text-[11px] text-muted-foreground">Ej. 24.1234</p>
</div>
</div>
<Dialog.Footer class="grid grid-cols-4 gap-2 pt-2">
<Button type="button" variant="outline" class="w-full text-xs" disabled>
Cargar TC
</Button>
<Button type="button" variant="outline" class="w-full text-xs" disabled>Ayuda</Button>
<Button
type="button"
variant="ghost"
onclick={() => (open = false)}
disabled={loading}
class="w-full hover:bg-muted/50"
>
Cancelar
</Button>
<Button type="submit" disabled={loading} class="w-full font-medium shadow-sm">
{#if loading}
<span class="mr-2 animate-spin"></span>
{:else}
Ok
{/if}
</Button>
</Dialog.Footer>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<AlertDialog.Root bind:open={showConfirmation}>
<AlertDialog.Content class="z-[10002]">
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description>
Se {isEdit ? 'actualizará' : 'creará'} el tipo de cambio con valor {formData.value} para el día {formData.date}.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => showConfirmation = false}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSubmit}>Confirmar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Content class="z-[10002]">
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description>
Se {isEdit ? 'actualizará' : 'creará'} el tipo de cambio con valor {formData.value} para el día
{formData.date}.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (showConfirmation = false)}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSubmit}>Confirmar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,10 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { uiStore } from '$lib/stores/ui.svelte';
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
import { page } from '$app/state';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
overlayClass = 'bg-black/5'
}: {
overlayClass?: string;
} = $props();
let open = $state(false);
let checked = $state(false);
@@ -30,8 +36,13 @@
const items = response.data?.items || [];
if (items.length === 0) {
console.log('[ExchangeRateGuard] No rate found, opening modal');
open = true;
console.log('[ExchangeRateGuard] No rate found, attempting to open modal');
if (!uiStore.isExchangeRateDialogOpen) {
uiStore.isExchangeRateDialogOpen = true;
open = true;
} else {
console.log('[ExchangeRateGuard] Modal already open, skipping');
}
}
} catch (error) {
console.error('[ExchangeRateGuard] Error checking exchange rate:', error);
@@ -41,16 +52,31 @@
}
$effect(() => {
const isDashboard = page.url.pathname === '/dashboard' || page.url.pathname === '/dashboard/';
if (companyStore.activeCompany?.id && !checked && isDashboard) {
if (companyStore.activeCompany?.id && !checked) {
checkExchangeRate();
}
});
function handleSuccess() {
console.log('Exchange rate created successfully via guard');
uiStore.isExchangeRateDialogOpen = false;
checked = true;
}
$effect(() => {
// Sincronizar el estado global con el estado local
if (open) {
uiStore.isExchangeRateDialogOpen = true;
} else if (checked) {
// Solo limpiar si este componente ya hizo su check inicial
// y el estado local es cerrado.
// Usamos una pequeña comprobación para no pisar a otros si fuera necesario,
// pero como son excluyentes, esto debería bastar.
if (uiStore.isExchangeRateDialogOpen && !open) {
uiStore.isExchangeRateDialogOpen = false;
}
}
});
</script>
<CreateEditDialog bind:open onSuccess={handleSuccess} overlayClass="bg-black/20" />
<CreateEditDialog bind:open onSuccess={handleSuccess} {overlayClass} />

View File

@@ -0,0 +1,17 @@
/**
* UI Store for managing global UI states
*/
class UIStore {
private _isExchangeRateDialogOpen = $state(false);
get isExchangeRateDialogOpen() {
return this._isExchangeRateDialogOpen;
}
set isExchangeRateDialogOpen(value: boolean) {
this._isExchangeRateDialogOpen = value;
}
}
export const uiStore = new UIStore();

View File

@@ -2,18 +2,18 @@
import { setContext, onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { LayoutData } from './$types';
import AppSidebar from "$lib/components/sidebar/app-sidebar.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { companyStore } from "$lib/stores/company.svelte";
import ExchangeRateGuard from "$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte";
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { companyStore } from '$lib/stores/company.svelte';
import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte';
let { data, children }: { data: LayoutData; children: any } = $props();
// Hacer disponible el usuario en el contexto para los componentes hijos
setContext('user', data.user);
// Inicializar el store con las compañías pre-cargadas desde el servidor
onMount(() => {
if (data.companies) {
@@ -36,7 +36,7 @@
<AppSidebar />
<Sidebar.Inset class="overflow-x-hidden">
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear"
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
@@ -56,11 +56,11 @@
-->
</div>
</header>
<div class="flex flex-1 flex-col gap-4 p-4 pt-0 overflow-x-hidden">
<div class="flex flex-1 flex-col gap-4 overflow-x-hidden p-4 pt-0">
<!-- Contenido de cada página -->
{@render children()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
<ExchangeRateGuard />
<ExchangeRateGuard overlayClass="bg-black/5" />

View File

@@ -8,6 +8,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Separator } from '$lib/components/ui/separator';
import { toast } from 'svelte-sonner';
import { uiStore } from '$lib/stores/ui.svelte';
import {
ArrowLeft,
FileText,
@@ -109,11 +110,22 @@
data.invoice?.financials?.exchange_rate ?? null
);
// Guardar la fecha inicial para detectar cambios manuales
let initialInvoiceDate = data.invoice?.invoice_date || '';
let showExchangeRateDialog = $state(false);
$effect(() => {
if (showExchangeRateDialog) {
uiStore.isExchangeRateDialogOpen = true;
} else {
// Solo limpiar si este componente tenía el modal abierto
if (uiStore.isExchangeRateDialogOpen && !showExchangeRateDialog) {
uiStore.isExchangeRateDialogOpen = false;
}
}
});
let missingExchangeRateDate = $state('');
let lastFetchedDate = $state('');
let originalInvoiceDate = $state(data.invoice?.invoice_date || '');
async function checkExchangeRate(date: string): Promise<boolean> {
if (!date || !companyStore?.activeCompany?.id) return true;
@@ -133,8 +145,13 @@
if (items.length === 0) {
console.log('No exchange rate found for', date);
missingExchangeRateDate = date;
showExchangeRateDialog = true;
if (!uiStore.isExchangeRateDialogOpen) {
missingExchangeRateDate = date;
showExchangeRateDialog = true;
uiStore.isExchangeRateDialogOpen = true;
return false;
}
// Si ya hay un modal abierto, no abrir este pero retornar false para indicar que falta
return false;
}
return true;
@@ -154,29 +171,38 @@
$effect(() => {
const currentDate = InvoiceTopFieldsFormData?.invoice_date;
// Solo proceder si:
// 1. Tenemos datos cargados y compañía activa
// 2. Es una factura nueva (isCreate)
// 3. O la fecha es distinta a la original (el usuario la cambió)
// 4. O no tenemos ningún tipo de cambio todavía
if (mounted && companyStore?.activeCompany?.id && currentDate) {
// No buscar si:
// 1. Ya tenemos un valor y la fecha es la original (evita sobreescribir al cargar)
const currentRate = calculatedExchangeRate;
if (
!data.isCreate &&
currentDate === originalInvoiceDate &&
typeof currentRate === 'number' &&
currentRate > 0
) {
return;
}
const shouldFetch =
mounted &&
companyStore?.activeCompany?.id &&
currentDate &&
(data.isCreate || currentDate !== initialInvoiceDate || !calculatedExchangeRate);
// 2. Ya buscamos para esta fecha recientemente
if (currentDate === lastFetchedDate) {
return;
}
if (shouldFetch) {
getExchangeRateByDate(currentDate, companyStore.activeCompany.id)
.then((rate) => {
lastFetchedDate = currentDate;
if (rate) {
calculatedExchangeRate = rate.value;
} else {
// Si no hay en catálogo, pero YA teníamos uno en la factura (y no cambió fecha), NO poner 0
if (currentDate === initialInvoiceDate && data.invoice?.financials?.exchange_rate) {
calculatedExchangeRate = data.invoice.financials.exchange_rate;
} else {
calculatedExchangeRate = 0;
calculatedExchangeRate = 0;
// Solo abrir modal si NO es la fecha de hoy (el guard global la maneja)
// Y si no hay otro modal de TC abierto ya
const today = new Date().toISOString().split('T')[0];
if (currentDate !== today && !uiStore.isExchangeRateDialogOpen) {
missingExchangeRateDate = currentDate;
showExchangeRateDialog = true;
uiStore.isExchangeRateDialogOpen = true;
}
}
})
@@ -429,7 +455,14 @@
<ExchangeRateDialog
bind:open={showExchangeRateDialog}
initialDate={missingExchangeRateDate}
overlayClass="bg-black/5"
onSuccess={() => {
uiStore.isExchangeRateDialogOpen = false;
// Si es factura nueva y no tiene fecha, poner la de hoy
if (data.isCreate && !InvoiceTopFieldsFormData.invoice_date) {
InvoiceTopFieldsFormData.invoice_date = new Date().toISOString().split('T')[0];
}
// Actualizar el tipo de cambio mostrado
if (InvoiceTopFieldsFormData.invoice_date && companyStore?.activeCompany?.id) {
getExchangeRateByDate(