325 lines
9.5 KiB
Svelte
325 lines
9.5 KiB
Svelte
<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';
|
|
|
|
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'
|
|
);
|
|
|
|
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);
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 confirmSubmit() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const companyId = companyStore.activeCompany?.id;
|
|
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
|
|
|
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 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}
|
|
|
|
<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-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>
|
|
|
|
<div class="grid gap-2">
|
|
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
|
|
>Tipo de Cambio (MXN/USD)</Label
|
|
>
|
|
<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"
|
|
lang="en"
|
|
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>
|
|
<div class="mt-1 flex items-start justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="h-8 font-medium shadow-sm transition-all"
|
|
disabled={scraping || loading || !formData.date}
|
|
onclick={() => fetchFromDof(formData.date)}
|
|
>
|
|
{#if scraping}
|
|
<span class="mr-2 animate-spin">⟳</span> Consultando...
|
|
{:else}
|
|
<CloudDownload size={15} class="mr-2" /> Consultar DOF
|
|
{/if}
|
|
</Button>
|
|
<p class="mt-2 px-1 text-right text-[11px] text-muted-foreground">Ej. 24.1234</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog.Footer class="mt-4 grid grid-cols-2 gap-3 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
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>
|