Files
plantillas-proyectos/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte

336 lines
10 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';
import { m } from '$lib/i18n/messages';
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
? m.exchange_rate_edit_title()
: isMissingRateContext
? m.exchange_rate_required_title()
: m.exchange_rate_new_title()
);
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(m.exchange_rate_toast_dof_success({ value: String(response.data.value) }));
} else {
toast.error(
response.data?.message || response.error || m.exchange_rate_toast_dof_error()
);
// No bloquear, permitir manual
}
} catch (e) {
console.error(e);
toast.error(m.exchange_rate_toast_dof_service_error());
} finally {
scraping = false;
}
}
function handleSubmit() {
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error(m.exchange_rate_error_no_company());
if (!formData.date) throw new Error(m.exchange_rate_error_date_required());
if (
formData.value === null ||
formData.value === undefined ||
String(formData.value).trim() === ''
)
throw new Error(m.exchange_rate_error_value_required());
if (Number(formData.value) <= 0)
throw new Error(m.exchange_rate_error_value_positive());
showConfirmation = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Error';
}
}
async function confirmSubmit() {
loading = true;
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error(m.exchange_rate_error_no_company());
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(m.exchange_rate_toast_update_success());
} else {
await createExchangeRate(dataToSend, companyId);
toast.success(m.exchange_rate_toast_create_success());
}
showConfirmation = false;
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Error';
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"
onInteractOutside={(e) => e.preventDefault()}
>
<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">
{m.exchange_rate_required_description()}
</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"
>{m.exchange_rate_applicable_date()}</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"
>
{m.exchange_rate_required_badge()}
</div>
{/if}
</div>
</div>
<div class="grid gap-2">
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
>{m.exchange_rate_exchange_rate_label()}</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> {m.exchange_rate_consulting()}
{:else}
<CloudDownload size={15} class="mr-2" /> {m.exchange_rate_consult_dof()}
{/if}
</Button>
<p class="mt-2 px-1 text-right text-[11px] text-muted-foreground">{m.exchange_rate_example_suffix()} 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"
>
{m.exchange_rate_cancel()}
</Button>
<Button type="submit" disabled={loading} class="w-full font-medium shadow-sm">
{#if loading}
<span class="mr-2 animate-spin"></span>
{:else}
{m.exchange_rate_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>{m.exchange_rate_confirm_title()}</AlertDialog.Title>
<AlertDialog.Description>
{#if isEdit}
{m.exchange_rate_confirm_description_update({ value: String(formData.value), date: formData.date })}
{:else}
{m.exchange_rate_confirm_description_create({ value: String(formData.value), date: formData.date })}
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (showConfirmation = false)}>{m.exchange_rate_cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSubmit}>{m.exchange_rate_confirm_action()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>