Tipo de cambio actualizado
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
17
frontend/src/lib/stores/ui.svelte.ts
Normal file
17
frontend/src/lib/stores/ui.svelte.ts
Normal 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();
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user