Merge pull request 'feature/dashboard-lenguage' (#359) from feature/dashboard-lenguage into development

Reviewed-on: ADUANASOFT/anexo76#359
This commit is contained in:
2026-05-02 02:41:53 +00:00
9 changed files with 324 additions and 110 deletions

View File

@@ -11,6 +11,8 @@
AlertCircle,
TruckIcon
} from 'lucide-svelte';
import { m } from '$lib/i18n/messages';
import { getLocale } from '$lib/paraglide/runtime';
interface Props {
activities: ActivityItem[];
@@ -48,7 +50,7 @@
// Si la fecha es futura (diff negativo) o muy reciente, mostrar 'Ahora mismo'
if (diffMs < 0) {
return 'Ahora mismo';
return m.dashboard_just_now();
}
const diffMins = Math.floor(diffMs / 60000);
@@ -56,15 +58,16 @@
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return 'Ahora mismo';
return m.dashboard_just_now();
} else if (diffMins < 60) {
return `Hace ${diffMins} min`;
return `${m.dashboard_ago_suffix()} ${diffMins} ${m.dashboard_min_short()}`;
} else if (diffHours < 24) {
return `Hace ${diffHours}h`;
return `${m.dashboard_ago_suffix()} ${diffHours}${m.dashboard_h_short()}`;
} else if (diffDays < 7) {
return `Hace ${diffDays}d`;
return `${m.dashboard_ago_suffix()} ${diffDays}${m.dashboard_d_short()}`;
} else {
return date.toLocaleDateString('es-MX', { month: 'short', day: 'numeric' });
const dateLocale = getLocale() === 'es' ? 'es-MX' : 'en-US';
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' });
}
}
</script>
@@ -75,12 +78,12 @@
<div>
<Card.Title class="flex items-center gap-2">
<Clock class="h-4 w-4 text-primary" />
Actividad Reciente
{m.dashboard_recent_activity()}
</Card.Title>
<Card.Description class="mt-0.5">Últimas operaciones registradas en el sistema</Card.Description>
<Card.Description class="mt-0.5">{m.dashboard_latest_operations()}</Card.Description>
</div>
{#if activities.length > 0}
<span class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">{activities.length} registros</span>
<span class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">{activities.length} {m.dashboard_records_suffix()}</span>
{/if}
</div>
</Card.Header>
@@ -88,7 +91,7 @@
{#if activities.length === 0}
<div class="flex flex-col items-center justify-center py-10 text-muted-foreground gap-3">
<Clock class="h-8 w-8 opacity-30" />
<p class="text-sm">No hay actividad reciente</p>
<p class="text-sm">{m.dashboard_no_recent_activity()}</p>
</div>
{:else}
<div class="relative">

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import type { ChartDataPoint } from '$lib/api/dashboard/types';
import { m } from '$lib/i18n/messages';
interface Props {
title: string;
@@ -41,7 +42,7 @@
<Card.Content>
{#if data.length === 0}
<div class="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground">
<p class="text-sm">No hay datos disponibles</p>
<p class="text-sm">{m.dashboard_no_data_available_short()}</p>
</div>
{:else if type === 'bar'}
<div class="space-y-3">

View File

@@ -2,6 +2,7 @@
import * as Card from '$lib/components/ui/card';
import { PieChart } from 'lucide-svelte';
import type { ChartDataPoint } from '$lib/api/dashboard/types';
import { m } from '$lib/i18n/messages';
interface Props {
title: string;
@@ -60,10 +61,10 @@
<PieChart class="h-4 w-4 text-blue-500" />
{title}
</Card.Title>
<Card.Description class="mt-1">Desglose por tipo de operación</Card.Description>
<Card.Description class="mt-1">{m.dashboard_operations_breakdown()}</Card.Description>
</div>
{#if total > 0}
<span class="text-sm font-semibold tabular-nums text-muted-foreground">{total.toLocaleString()} ops</span>
<span class="text-sm font-semibold tabular-nums text-muted-foreground">{total.toLocaleString()} {m.dashboard_ops_short()}</span>
{/if}
</div>
</Card.Header>
@@ -75,8 +76,8 @@
<PieChart class="h-8 w-8 opacity-30" />
</div>
<div class="text-center">
<p class="text-sm font-medium">Sin datos disponibles</p>
<p class="text-xs text-muted-foreground/70 mt-0.5">Las operaciones aparecerán aquí una vez registradas</p>
<p class="text-sm font-medium">{m.dashboard_no_data_available()}</p>
<p class="text-xs text-muted-foreground/70 mt-0.5">{m.dashboard_operations_will_appear_here()}</p>
</div>
</div>
{:else}
@@ -102,7 +103,7 @@
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-bold tabular-nums leading-none">{total.toLocaleString()}</span>
<span class="text-[11px] text-muted-foreground mt-0.5">total</span>
<span class="text-[11px] text-muted-foreground mt-0.5">{m.dashboard_total()}</span>
</div>
</div>

View File

@@ -22,6 +22,7 @@
} from 'lucide-svelte';
import { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { m } from '$lib/i18n/messages';
let {
open = $bindable(false),
@@ -41,10 +42,10 @@
const isMissingRateContext = $derived(!isEdit && initialDate);
const title = $derived(
isEdit
? 'Editar Tipo de Cambio'
? m.exchange_rate_edit_title()
: isMissingRateContext
? 'Tipo de Cambio Requerido'
: 'Nuevo Tipo de Cambio'
? m.exchange_rate_required_title()
: m.exchange_rate_new_title()
);
let formData = $state({
@@ -98,16 +99,16 @@
// 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}`);
toast.success(m.exchange_rate_toast_dof_success({ value: String(response.data.value) }));
} else {
toast.error(
response.data?.message || response.error || 'No se pudo obtener el dato del DOF'
response.data?.message || response.error || m.exchange_rate_toast_dof_error()
);
// No bloquear, permitir manual
}
} catch (e) {
console.error(e);
toast.error('Error al consultar el servicio del DOF');
toast.error(m.exchange_rate_toast_dof_service_error());
} finally {
scraping = false;
}
@@ -117,20 +118,20 @@
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 (!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('El tipo de cambio es requerido');
throw new Error(m.exchange_rate_error_value_required());
if (Number(formData.value) <= 0)
throw new Error('El tipo de cambio debe ser un valor mayor a 0');
throw new Error(m.exchange_rate_error_value_positive());
showConfirmation = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Error al validar';
error = e instanceof Error ? e.message : 'Error';
}
}
@@ -139,7 +140,7 @@
error = null;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!companyId) throw new Error(m.exchange_rate_error_no_company());
const dataToSend = {
date: formData.date,
@@ -150,17 +151,17 @@
if (isEdit && item) {
await updateExchangeRate(item.id, dataToSend, companyId);
toast.success('Tipo de cambio actualizado correctamente');
toast.success(m.exchange_rate_toast_update_success());
} else {
await createExchangeRate(dataToSend, companyId);
toast.success('Tipo de cambio creado correctamente');
toast.success(m.exchange_rate_toast_create_success());
}
showConfirmation = false;
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar';
error = e instanceof Error ? e.message : 'Error';
showConfirmation = false;
} finally {
loading = false;
@@ -192,8 +193,7 @@
{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.
{m.exchange_rate_required_description()}
</p>
</div>
</div>
@@ -226,7 +226,7 @@
<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
>{m.exchange_rate_applicable_date()}</Label
>
<div class="relative">
<Input
@@ -241,7 +241,7 @@
<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
{m.exchange_rate_required_badge()}
</div>
{/if}
</div>
@@ -249,7 +249,7 @@
<div class="grid gap-2">
<Label for="value" class="ml-1 text-sm font-medium text-muted-foreground"
>Tipo de Cambio (MXN/USD)</Label
>{m.exchange_rate_exchange_rate_label()}</Label
>
<div class="group relative">
<div
@@ -280,12 +280,12 @@
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="mr-2 animate-spin"></span> Consultando...
<span class="mr-2 animate-spin"></span> {m.exchange_rate_consulting()}
{:else}
<CloudDownload size={15} class="mr-2" /> Consultar DOF
<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">Ej. 24.1234</p>
<p class="mt-2 px-1 text-right text-[11px] text-muted-foreground">{m.exchange_rate_example_suffix()} 24.1234</p>
</div>
</div>
</div>
@@ -298,13 +298,13 @@
disabled={loading}
class="w-full hover:bg-muted/50"
>
Cancelar
{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}
Ok
{m.exchange_rate_ok()}
{/if}
</Button>
</Dialog.Footer>
@@ -317,15 +317,18 @@
<AlertDialog.Root bind:open={showConfirmation}>
<AlertDialog.Content class="z-[10002]">
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Title>{m.exchange_rate_confirm_title()}</AlertDialog.Title>
<AlertDialog.Description>
Se {isEdit ? 'actualizará' : 'creará'} el tipo de cambio con valor {formData.value} para el día
{formData.date}.
{#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)}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmSubmit}>Confirmar</AlertDialog.Action>
<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>

View File

@@ -11,6 +11,7 @@
} from '$lib/api/dashboard/a76/general_catalogs/multi-currency-types';
import { companyStore } from '$lib/stores/company.svelte';
import { obtenerAtajosFormularioMonedas } from '$lib/config/shortcuts/dashboard/general_catalogs/multi_currency_types/edit';
import { m } from '$lib/i18n/messages';
let {
open = $bindable(false),
@@ -26,7 +27,7 @@
const isEdit = $derived(!!item);
const title = $derived(
isEdit ? 'Editar Tipo de Cambio Múltiple' : 'Nuevo Tipo de Cambio Múltiple'
isEdit ? m.multi_currency_edit_title() : m.multi_currency_new_title()
);
// Estado del formulario
@@ -78,11 +79,11 @@
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
if (!companyId) throw new Error(m.exchange_rate_error_no_company());
// Validaciones
if (!formData.currency_type_code.trim()) throw new Error('El código de moneda es requerido');
if (!formData.date_str) throw new Error('La fecha de publicación es requerida');
if (!formData.currency_type_code.trim()) throw new Error(m.multi_currency_error_currency_required());
if (!formData.date_str) throw new Error(m.multi_currency_error_date_required());
// Truco: Convertir String ("2025-12-31") -> Entero (20251231)
// Quitamos los guiones y parseamos a int
@@ -110,7 +111,7 @@
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar';
error = e instanceof Error ? e.message : 'Error';
} finally {
loading = false;
}
@@ -139,38 +140,38 @@
<div class="grid gap-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="currency_code" class="text-right"
>Moneda <span class="text-destructive">*</span></Label
>{m.multi_currency_currency_label()} <span class="text-destructive">*</span></Label
>
<div class="col-span-3">
<Input
id="currency_code"
bind:value={formData.currency_type_code}
placeholder="Ej: USD"
placeholder="{m.exchange_rate_example_suffix()} USD"
maxlength={3}
disabled={loading}
required
/>
<p class="text-[10px] text-muted-foreground mt-1">Código de moneda (FK).</p>
<p class="text-[10px] text-muted-foreground mt-1">{m.multi_currency_currency_help()}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="country_key" class="text-right">País</Label>
<Label for="country_key" class="text-right">{m.multi_currency_country_label()}</Label>
<div class="col-span-3">
<Input
id="country_key"
bind:value={formData.country_key}
placeholder="Ej: MEX"
placeholder="{m.exchange_rate_example_suffix()} MEX"
maxlength={3}
disabled={loading}
/>
<p class="text-[10px] text-muted-foreground mt-1">Clave M3 del país (FK).</p>
<p class="text-[10px] text-muted-foreground mt-1">{m.multi_currency_country_help()}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pub_date" class="text-right"
>Fecha <span class="text-destructive">*</span></Label
>{m.multi_currency_date_label()} <span class="text-destructive">*</span></Label
>
<div class="col-span-3">
<Input
@@ -180,12 +181,12 @@
disabled={loading}
required
/>
<p class="text-[10px] text-muted-foreground mt-1">Se guarda como entero (YYYYMMDD).</p>
<p class="text-[10px] text-muted-foreground mt-1">{m.multi_currency_date_help()}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="factor" class="text-right">Factor</Label>
<Label for="factor" class="text-right">{m.multi_currency_factor_label()}</Label>
<div class="col-span-3">
<Input
id="factor"
@@ -201,10 +202,10 @@
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
{m.exchange_rate_cancel()}
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
{loading ? m.multi_currency_saving() : isEdit ? m.multi_currency_update() : m.multi_currency_create()}
</Button>
</Dialog.Footer>
</form>

View File

@@ -3,6 +3,7 @@
import { TrendingUp, TrendingDown, Minus, LineChart } from 'lucide-svelte';
import type { ChartDataPoint } from '$lib/api/dashboard/types';
import { onDestroy, tick } from 'svelte';
import { m } from '$lib/i18n/messages';
interface Props {
monthlyData: ChartDataPoint[];
@@ -67,7 +68,7 @@
labels: monthlyData.map((d) => d.label),
datasets: [
{
label: 'Operaciones',
label: m.dashboard_operations(),
data: monthlyData.map((d) => d.value),
borderColor: blue,
borderWidth: 2.5,
@@ -103,7 +104,7 @@
cornerRadius: 10,
boxPadding: 4,
callbacks: {
label: (ctx: any) => ` ${Number(ctx.raw).toLocaleString()} operaciones`
label: (ctx: any) => ` ${Number(ctx.raw).toLocaleString()} ${m.dashboard_operations()}`
}
}
},
@@ -146,9 +147,9 @@
<div>
<Card.Title class="flex items-center gap-2 text-base">
<LineChart class="h-4 w-4 text-blue-500" />
Tendencia de Operaciones
{m.dashboard_operations_trend()}
</Card.Title>
<Card.Description class="mt-1">Evolución mensual de operaciones</Card.Description>
<Card.Description class="mt-1">{m.dashboard_monthly_evolution()}</Card.Description>
</div>
{#if monthlyData.length >= 2}
@@ -162,7 +163,7 @@
{:else}
<Minus class="h-3 w-3" />0%
{/if}
<span class="opacity-60 ml-0.5">vs mes ant.</span>
<span class="opacity-60 ml-0.5">vs {m.dashboard_previous_month()}</span>
</div>
{/if}
</div>
@@ -175,8 +176,8 @@
<LineChart class="h-8 w-8 text-blue-300" />
</div>
<div class="text-center">
<p class="text-sm font-medium">Sin datos disponibles</p>
<p class="text-xs text-muted-foreground/70 mt-0.5">Los datos aparecerán aquí una vez registrados</p>
<p class="text-sm font-medium">{m.dashboard_no_data_available()}</p>
<p class="text-xs text-muted-foreground/70 mt-0.5">{m.dashboard_data_will_appear_here()}</p>
</div>
</div>
{:else if monthlyData.length === 1}
@@ -184,12 +185,12 @@
<div class="text-center">
<div class="text-5xl font-bold text-blue-600 tabular-nums">{monthlyData[0].value.toLocaleString()}</div>
<div class="text-sm text-muted-foreground mt-2">
operaciones en <span class="font-semibold text-foreground">{monthlyData[0].label}</span>
{m.dashboard_operations_in()} <span class="font-semibold text-foreground">{monthlyData[0].label}</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-blue-50 text-blue-600 rounded-full px-4 py-2">
<LineChart class="h-3.5 w-3.5 shrink-0" />
La gráfica aparecerá con más de un mes de datos
{m.dashboard_more_than_one_month_needed()}
</div>
</div>
{:else}
@@ -202,15 +203,15 @@
<div class="grid grid-cols-3 divide-x border-t mt-4 pt-4">
<div class="px-4 first:pl-0 last:pr-0">
<div class="text-lg font-semibold tabular-nums text-blue-600">{total.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Total</div>
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_total()}</div>
</div>
<div class="px-4">
<div class="text-lg font-semibold tabular-nums">{average.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Promedio / mes</div>
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_average_per_month()}</div>
</div>
<div class="px-4 text-right">
<div class="text-lg font-semibold tabular-nums">{maxValue.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Máximo</div>
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_maximum()}</div>
</div>
</div>
{/if}