i18n: enforce strict localization consistency across all pages and components

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-14 01:34:00 +00:00
parent 086a37f43c
commit 466dd0e59c
12 changed files with 524 additions and 188 deletions

View file

@ -64,7 +64,7 @@ export default function AuditLogsPage() {
};
if (authLoading || (role !== 'admin' && role !== null)) {
return <div className={styles.loading}>Verificando credenciales...</div>;
return <div className={styles.loading}>{t('audit.loading')}</div>;
}
if (role !== 'admin') {
@ -83,7 +83,7 @@ export default function AuditLogsPage() {
{/* Audit Logs Table */}
<div className={styles.tableContainer}>
{isLoading ? (
<div className={styles.loading}>Cargando registros...</div>
<div className={styles.loading}>{t('audit.loading')}</div>
) : logs.length === 0 ? (
<div className={styles.emptyState}>{t('audit.empty')}</div>
) : (
@ -135,9 +135,9 @@ export default function AuditLogsPage() {
<div>
<div className={styles.panelHeaderInfo}>
<div><strong>ID:</strong> {selectedLog.id}</div>
<div><strong>Actor:</strong> {selectedLog.user.username} ({selectedLog.user.email})</div>
<div><strong>Acción:</strong> {selectedLog.action}</div>
<div><strong>Tabla:</strong> {selectedLog.targetTable} (ID: {selectedLog.targetId})</div>
<div><strong>{t('audit.actor')}:</strong> {selectedLog.user.username} ({selectedLog.user.email})</div>
<div><strong>{t('audit.action')}:</strong> {selectedLog.action}</div>
<div><strong>{t('audit.target_table')}:</strong> {selectedLog.targetTable} (ID: {selectedLog.targetId})</div>
<div><strong>Fecha:</strong> {new Date(selectedLog.createdAt).toLocaleString()}</div>
<div><strong>IP:</strong> {selectedLog.ipAddress || '-'}</div>
</div>
@ -174,7 +174,7 @@ export default function AuditLogsPage() {
</div>
) : (
<div className={styles.emptyDetails}>
Seleccione una fila para ver el JSON Diff
{t('audit.no_diff')}
</div>
)}
</div>

View file

@ -82,7 +82,7 @@ export default function DashboardPage() {
};
if (authLoading || (!isRoleAuthorized && role !== null)) {
return <div className={styles.loading}>Verificando credenciales...</div>;
return <div className={styles.loading}>{t('dashboard.loading')}</div>;
}
if (!isRoleAuthorized) {
@ -104,7 +104,7 @@ export default function DashboardPage() {
</div>
{isLoading ? (
<div className={styles.loading}>Cargando panel de control...</div>
<div className={styles.loading}>{t('dashboard.loading')}</div>
) : (
<>
{/* KPI Cards Grid */}

View file

@ -113,11 +113,11 @@ export default function GoalsPage() {
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al asignar la meta.');
setError(data.error || t('goals.errorSave'));
return;
}
setSuccess('Meta comercial asignada / actualizada exitosamente.');
setSuccess(t('goals.successSave'));
setAmount('');
// Reload goals list
const goalsRes = await fetch('/api/goals');
@ -126,7 +126,7 @@ export default function GoalsPage() {
setGoals(goalsData.goals || []);
}
} catch (err) {
setError('Ocurrió un error al guardar la meta.');
setError(t('goals.errorSave'));
}
};
@ -147,34 +147,34 @@ export default function GoalsPage() {
<main className={styles.main}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Metas Comerciales</h1>
<h1 className={styles.title}>{t('goals.title')}</h1>
</div>
{/* Goal Assignment Form Section */}
{(role === 'admin' || role === 'director') && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Asignar Meta</h2>
<h2 className={styles.sectionTitle}>{t('goals.assignTitle')}</h2>
{error && <div className={styles.errorMsg} id="goal-error-msg">{error}</div>}
{success && <div className={styles.successMsg} id="goal-success-msg">{success}</div>}
<form onSubmit={handleAssignGoal} className={styles.form}>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-type">Tipo de Meta</label>
<label className={styles.label} htmlFor="goal-target-type">{t('goals.labelType')}</label>
<select
id="goal-target-type"
className={styles.input}
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
>
<option value="INDIVIDUAL">Colaborador Individual</option>
<option value="TEAM">Equipo</option>
<option value="HOTEL">Hotel</option>
<option value="INDIVIDUAL">{t('goals.roles.collaborator')}</option>
<option value="TEAM">{t('targetType.TEAM')}</option>
<option value="HOTEL">{t('targetType.HOTEL')}</option>
</select>
</div>
{targetType === 'INDIVIDUAL' && (
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-id">Seleccionar Colaborador</label>
<label className={styles.label} htmlFor="goal-target-id">{t('goals.labelSelect')}</label>
<select
id="goal-target-id"
className={styles.input}
@ -193,21 +193,21 @@ export default function GoalsPage() {
{targetType !== 'INDIVIDUAL' && (
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-target-id-input">ID del Objetivo (Hotel/Equipo)</label>
<label className={styles.label} htmlFor="goal-target-id-input">ID del Objetivo ({t('targetType.HOTEL')}/{t('targetType.TEAM')})</label>
<input
id="goal-target-id-input"
type="number"
className={styles.input}
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="Ej. ID de Hotel o Equipo"
placeholder="Ej. ID de {t('targetType.HOTEL')} o {t('targetType.TEAM')}"
required
/>
</div>
)}
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-period">Período (Mes/Año)</label>
<label className={styles.label} htmlFor="goal-period">{t('goals.thPeriod')} (Mes/Año)</label>
<input
id="goal-period"
type="month"
@ -219,7 +219,7 @@ export default function GoalsPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="goal-amount">Monto Quota ($)</label>
<label className={styles.label} htmlFor="goal-amount">{t('goals.thAmount')} Quota ($)</label>
<input
id="goal-amount"
type="number"
@ -233,7 +233,7 @@ export default function GoalsPage() {
</div>
<button type="submit" className={styles.btnPrimary} id="btn-save-goal">
Asignar Meta
{t('goals.assignTitle')}
</button>
</form>
</section>
@ -243,15 +243,15 @@ export default function GoalsPage() {
<section className={`${styles.section} ${!(role === 'admin' || role === 'director') ? styles.fullWidth : ''}`}>
<h2 className={styles.sectionTitle}>Historial de Metas</h2>
{isLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>Cargando metas...</div>
<div style={{ textAlign: 'center', padding: '20px' }}>{t('goals.loading')}</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Objetivo</th>
<th className={styles.th}>Período</th>
<th className={styles.th}>Tipo</th>
<th className={styles.th}>{t('goals.thPeriod')}</th>
<th className={styles.th}>{t('goals.thType')}</th>
<th className={styles.th}>Cuota ($)</th>
</tr>
</thead>

View file

@ -179,7 +179,7 @@ export default function HistoryPage() {
onChange={(e) => setSelectedUserId(e.target.value)}
id="filter-collaborator"
>
<option value="">Todos</option>
<option value="">{t('history.all_statuses')}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
@ -192,7 +192,7 @@ export default function HistoryPage() {
{/* History Table */}
{isLoading ? (
<div className={styles.loading}>Cargando historial...</div>
<div className={styles.loading}>{t('history.loading')}</div>
) : settlements.length === 0 ? (
<div className={styles.emptyState}>{t('history.empty')}</div>
) : (
@ -248,7 +248,7 @@ export default function HistoryPage() {
className={styles.expandBtn}
id={`btn-toggle-notes-${item.id}`}
>
{expandedRowId === item.id ? 'Ocultar' : 'Ver Notas'}
{expandedRowId === item.id ? t('history.hideNotes') : t('history.showNotes')}
</button>
) : (
<span style={{ fontSize: '0.75rem', opacity: 0.5 }}>-</span>

View file

@ -4,20 +4,19 @@
import React, { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import styles from './page.module.css';
import { useLocale } from '@/lib/i18n/LocaleContext';
function LoginForm() {
const router = useRouter();
const { t } = useLocale();
const searchParams = useSearchParams();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Read return url if any
const callbackUrl = searchParams.get('callbackUrl') || '/';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@ -35,15 +34,14 @@ function LoginForm() {
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed. Please check your credentials.');
setError(data.error || t('login.error'));
setIsLoading(false);
return;
}
// Redirect to target dashboard
window.location.href = callbackUrl;
} catch (err) {
setError('An unexpected error occurred. Please try again.');
setError(t('login.unexpectedError'));
setIsLoading(false);
}
};
@ -52,8 +50,8 @@ function LoginForm() {
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<div className={styles.subtitle}>Hoteles Estelar</div>
<h1 className={styles.title}>Iniciar Sesión</h1>
<div className={styles.subtitle}>{t('login.subtitle')}</div>
<h1 className={styles.title}>{t('login.title')}</h1>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
@ -66,7 +64,7 @@ function LoginForm() {
<div className={styles.group}>
<label className={styles.label} htmlFor="username">
Usuario
{t('login.username')}
</label>
<div className={styles.inputWrapper}>
<input
@ -76,7 +74,7 @@ function LoginForm() {
className={styles.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Ingrese su usuario"
placeholder={t('login.usernamePlaceholder')}
disabled={isLoading}
/>
</div>
@ -84,7 +82,7 @@ function LoginForm() {
<div className={styles.group}>
<label className={styles.label} htmlFor="password">
Contraseña
{t('login.password')}
</label>
<div className={styles.inputWrapper}>
<input
@ -94,19 +92,19 @@ function LoginForm() {
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Ingrese su contraseña"
placeholder={t('login.passwordPlaceholder')}
disabled={isLoading}
/>
</div>
</div>
<button type="submit" className={styles.button} disabled={isLoading}>
{isLoading ? <div className={styles.spinner} /> : 'Ingresar'}
{isLoading ? <div className={styles.spinner} /> : t('login.submit')}
</button>
</form>
<div className={styles.footer}>
Sistema de Remuneración Variable y Comisiones
{t('login.footer')}
</div>
</div>
</div>
@ -115,7 +113,7 @@ function LoginForm() {
export default function LoginPage() {
return (
<Suspense fallback={<div style={{ color: 'white', textAlign: 'center', marginTop: '50px' }}>Cargando formulario...</div>}>
<Suspense fallback={<div style={{ color: 'white', textAlign: 'center', marginTop: '50px' }}>Loading form...</div>}>
<LoginForm />
</Suspense>
);

View file

@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import styles from './page.module.css';
import Header from '@/components/Header';
import { useLocale } from '@/lib/i18n/LocaleContext';
interface Rule {
id?: number;
@ -28,6 +29,7 @@ interface Plan {
}
export default function RulesPage() {
const { t } = useLocale();
const router = useRouter();
const params = useParams();
const planId = params?.id ? parseInt(params.id as string) : null;
@ -72,10 +74,10 @@ export default function RulesPage() {
]);
}
} else {
setError('No se pudo encontrar el plan.');
setError(t('plans.errorFind'));
}
} catch (err) {
setError('Error al cargar la información del plan.');
setError(t('plans.errorLoad'));
} finally {
setIsLoading(false);
}
@ -112,7 +114,7 @@ export default function RulesPage() {
const handleDeleteRow = (tempId: string) => {
if (rules.length === 1) {
setError('El plan debe tener al menos una regla.');
setError(t('rules.errorAtLeastOne'));
return;
}
setRules(rules.filter(r => r.tempId !== tempId));
@ -146,11 +148,11 @@ export default function RulesPage() {
for (let i = 0; i < rules.length; i++) {
const r = rules[i];
if (r.minAchievement >= r.maxAchievement) {
setError(`Fila ${i + 1}: El logro mínimo (${r.minAchievement}) debe ser menor que el logro máximo (${r.maxAchievement}).`);
setError(t('rules.errorBoundary').replace('{row}', String(i + 1)).replace('{min}', String(r.minAchievement)).replace('{max}', String(r.maxAchievement)));
return;
}
if (r.minAchievement < 0 || r.maxAchievement < 0 || r.rate < 0 || r.payoutAmount < 0) {
setError(`Fila ${i + 1}: Todos los valores deben ser mayores o iguales a cero.`);
setError(t('rules.errorNegative').replace('{row}', String(i + 1)));
return;
}
}
@ -172,22 +174,22 @@ export default function RulesPage() {
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al guardar las reglas.');
setError(data.error || t('rules.errorSave'));
return;
}
setSuccess('Reglas configuradas y guardadas exitosamente.');
setSuccess(t('rules.successSave'));
// Refresh current plan
fetchPlan();
} catch (err) {
setError('Ocurrió un error de red al guardar las reglas.');
setError(t('rules.errorNetwork'));
}
};
if (isLoading) {
return (
<div className={styles.container}>
<div style={{ textAlign: 'center', padding: '100px' }}>Cargando configuración...</div>
<div style={{ textAlign: 'center', padding: '100px' }}>{t('rules.loading')}</div>
</div>
);
}
@ -200,11 +202,11 @@ export default function RulesPage() {
<main className={styles.main}>
<div className={styles.titleArea}>
<Link href="/plans" className={styles.backLink}>
&larr; Volver a Planes
&larr; {t('rules.back')}
</Link>
<h1 className={styles.title}>Configurar Reglas de Comisión</h1>
<h1 className={styles.title}>{t('rules.title')}</h1>
<p className={styles.subtitle}>
Plan: <strong>{plan?.name}</strong> | Código: {plan?.code} | Versión: {plan?.version} | Estado: {plan?.status}
{t('rules.subtitle').replace('{name}', plan?.name || '').replace('{code}', plan?.code || '').replace('{version}', String(plan?.version || '')).replace('{status}', t('status.' + plan?.status) || '')}
</p>
</div>
@ -214,11 +216,11 @@ export default function RulesPage() {
<div className={styles.section}>
<form onSubmit={handleSaveRules}>
<div className={styles.tableHeader}>
<span>Tipo de Regla</span>
<span>Min Logro (%)</span>
<span>Max Logro (%)</span>
<span>Tasa Comisión (Decimal)</span>
<span>Payout Fijo ($)</span>
<span>{t('rules.thType')}</span>
<span>{t('rules.thMin')}</span>
<span>{t('rules.thMax')}</span>
<span>{t('rules.thRate')}</span>
<span>{t('rules.thPayout')}</span>
<span></span>
</div>
@ -232,8 +234,8 @@ export default function RulesPage() {
id={`rule-type-${idx}`}
disabled={!(role === 'admin' || role === 'director')}
>
<option value="TIER">Rango (TIER)</option>
<option value="BONUS">Bono Fijo (BONUS)</option>
<option value="TIER">{t('rules.types.TIER')}</option>
<option value="BONUS">{t('rules.types.BONUS')}</option>
</select>
<input
@ -286,7 +288,7 @@ export default function RulesPage() {
onClick={() => handleDeleteRow(rule.tempId!)}
className={styles.btnDelete}
id={`btn-delete-rule-${idx}`}
title="Eliminar regla"
title={t('rules.delete')}
>
&times;
</button>
@ -302,17 +304,17 @@ export default function RulesPage() {
className={styles.btnSecondary}
id="btn-add-rule"
>
+ Agregar Rango / Regla
{t('rules.addRule')}
</button>
)}
<div className={styles.footerActions}>
<Link href="/plans" className={styles.btnSecondary} style={{ marginRight: 'auto' }}>
{ (role === 'admin' || role === 'director') ? 'Cancelar' : 'Volver' }
{ (role === 'admin' || role === 'director') ? t('rules.cancel') : t('rules.backBtn') }
</Link>
{(role === 'admin' || role === 'director') && (
<button type="submit" className={styles.btnPrimary} id="btn-save-rules">
Guardar Reglas
{t('rules.save')}
</button>
)}
</div>

View file

@ -143,16 +143,16 @@ export default function PlansPage() {
<main className={styles.main}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Planes de Comisión</h1>
<h1 className={styles.title}>{t('plans.title')}</h1>
{(role === 'admin' || role === 'director') && (
<button onClick={() => setShowModal(true)} className={styles.btnPrimary} id="btn-create-plan">
Nuevo Plan
{t('plans.newPlan')}
</button>
)}
</div>
{isLoading ? (
<div className={styles.loading}>Cargando planes...</div>
<div className={styles.loading}>{t('plans.loading')}</div>
) : (
<div className={styles.grid}>
{plans.map((plan) => (
@ -167,25 +167,25 @@ export default function PlansPage() {
<h2 className={styles.cardTitle}>{plan.name}</h2>
<div className={styles.cardMeta}>
<div className={styles.metaItem}>
<span>Versión:</span>
<span>{t('plans.version')}:</span>
<span className={styles.metaValue} data-version-id={plan.id}>{plan.version}</span>
</div>
<div className={styles.metaItem}>
<span>Inicio Vigencia:</span>
<span>{t('plans.startValidity')}:</span>
<span className={styles.metaValue}>
{new Date(plan.validityStart).toLocaleDateString()}
</span>
</div>
{plan.validityEnd && (
<div className={styles.metaItem}>
<span>Fin Vigencia:</span>
<span>{t('plans.endValidity')}:</span>
<span className={styles.metaValue}>
{new Date(plan.validityEnd).toLocaleDateString()}
</span>
</div>
)}
<div className={styles.metaItem}>
<span>Reglas creadas:</span>
<span>{t('plans.rulesCreated')}:</span>
<span className={styles.metaValue}>{plan.rules ? plan.rules.length : 0}</span>
</div>
</div>
@ -195,19 +195,19 @@ export default function PlansPage() {
{ (role === 'admin' || role === 'director') ? (
<>
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
Configurar Reglas
{t('plans.configureRules')}
</Link>
<button
onClick={() => handleToggleStatus(plan)}
className={styles.btnSecondary}
id={`btn-toggle-status-${plan.id}`}
>
{plan.status === 'ACTIVE' ? 'Inactivar (Versión)' : 'Activar'}
{plan.status === 'ACTIVE' ? t('plans.deactivateVersion') : t('plans.activate')}
</button>
</>
) : (
<Link href={`/plans/${plan.id}/rules`} className={styles.btnSecondary} id={`btn-rules-${plan.id}`}>
Ver Reglas
{t('plans.viewRules')}
</Link>
) }
</div>
@ -221,12 +221,12 @@ export default function PlansPage() {
{showModal && (
<div className={styles.modalOverlay} role="dialog">
<div className={styles.modal}>
<h2 className={styles.modalTitle}>Crear Nuevo Plan</h2>
<h2 className={styles.modalTitle}>Crear {t('plans.newPlan')}</h2>
<form onSubmit={handleCreatePlan} className={styles.form}>
{error && <div className={styles.errorText}>{error}</div>}
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-name">Nombre del Plan</label>
<label className={styles.label} htmlFor="plan-name">{t('plans.modalName')}</label>
<input
id="plan-name"
type="text"
@ -239,7 +239,7 @@ export default function PlansPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-code">Código del Plan</label>
<label className={styles.label} htmlFor="plan-code">{t('plans.modalCode')}</label>
<input
id="plan-code"
type="text"
@ -252,7 +252,7 @@ export default function PlansPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-validity-start">Inicio de Vigencia</label>
<label className={styles.label} htmlFor="plan-validity-start">{t('plans.modalValidityStart')}</label>
<input
id="plan-validity-start"
type="date"
@ -264,22 +264,22 @@ export default function PlansPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-type">Tipo de Remuneración</label>
<label className={styles.label} htmlFor="plan-type">{t('plans.modalType')}</label>
<select
id="plan-type"
className={styles.input}
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="PERCENTAGE">Porcentaje Simple</option>
<option value="SCALE">Escala Contigua</option>
<option value="CONDITIONAL">Condicional por Metas</option>
<option value="FIXED">Comisión Fija</option>
<option value="PERCENTAGE">{t('plans.types.PERCENTAGE')}</option>
<option value="SCALE">{t('plans.types.SCALE')}</option>
<option value="CONDITIONAL">{t('plans.types.CONDITIONAL')}</option>
<option value="FIXED">{t('plans.types.FIXED')}</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-meta-amount">Meta de Ventas (Opcional)</label>
<label className={styles.label} htmlFor="plan-meta-amount">{t('plans.modalMetaAmount')}</label>
<input
id="plan-meta-amount"
type="number"
@ -292,7 +292,7 @@ export default function PlansPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-max-cap">Tope Máximo / Cap (Opcional)</label>
<label className={styles.label} htmlFor="plan-max-cap">{t('plans.modalMaxCap')}</label>
<input
id="plan-max-cap"
type="number"
@ -305,7 +305,7 @@ export default function PlansPage() {
</div>
<div className={styles.formGroup}>
<label className={styles.label} htmlFor="plan-status">Estado Inicial</label>
<label className={styles.label} htmlFor="plan-status">{t('plans.modalStatus')}</label>
<select
id="plan-status"
className={styles.input}
@ -319,10 +319,10 @@ export default function PlansPage() {
<div className={styles.formActions}>
<button type="button" onClick={() => setShowModal(false)} className={styles.btnSecondary}>
Cancelar
{t('plans.modalCancel')}
</button>
<button type="submit" className={styles.btnPrimary} id="btn-save-plan">
Guardar Plan
{t('plans.modalSave')}
</button>
</div>
</form>

View file

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import Header from '@/components/Header';
import styles from './page.module.css';
import { useUser } from '@/lib/auth/UserContext';
import { useLocale } from '@/lib/i18n/LocaleContext';
interface ValidationError {
row: number;
@ -14,39 +15,9 @@ interface ValidationError {
metadata: any;
}
const TRANSLATIONS: Record<string, string> = {
USER_NOT_FOUND: "El colaborador '{username}' no existe en el sistema.",
INVALID_PERIOD_FORMAT: "El período '{value}' no tiene formato válido (esperado: {expected}).",
HOTEL_NOT_FOUND: "El hotel '{hotelCode}' no existe en el sistema.",
HOTEL_REGION_MISMATCH: "El hotel '{hotelCode}' no pertenece a su región autorizada.",
INVALID_AMOUNT: "El monto '{value}' no es válido. Debe ser un número positivo.",
INVALID_COUNT: "La cantidad '{value}' no es válida. Debe ser un número entero positivo.",
USER_REQUIRED: "El colaborador es obligatorio.",
HOTEL_REQUIRED: "El hotel es obligatorio.",
IMPORT_VALIDATION_FAILED: "El archivo cargado contiene inconsistencias de validación.",
MISSING_IDEMPOTENCY_KEY: "El encabezado de idempotencia es obligatorio.",
FILE_REQUIRED: "Debe seleccionar un archivo válido.",
EMPTY_FILE: "El archivo está vacío y no contiene registros.",
INTEGRATION_ERROR: "El motor de integraciones (n8n) reportó un fallo al procesar la solicitud.",
IMPORT_ALREADY_PROCESSED: "Este archivo ya fue cargado y procesado con éxito anteriormente.",
IMPORT_SUCCESSFUL: "Importación completada con éxito. Se cargaron {count} registros.",
IMPORT_ACCEPTED: "La importación ha sido aceptada y se está procesando mediante n8n en segundo plano."
};
const translate = (code: string, metadata: any = {}, value?: any) => {
let template = TRANSLATIONS[code] || code;
const merged = { ...metadata };
if (value !== undefined) {
merged.value = value;
}
for (const key of Object.keys(merged)) {
template = template.replace(`{${key}}`, String(merged[key]));
}
return template;
};
export default function SalesImportPage() {
const router = useRouter();
const { t } = useLocale();
const [file, setFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@ -58,6 +29,21 @@ export default function SalesImportPage() {
const [statusMessage, setStatusMessage] = useState('');
const { role, user: currentUser, isLoading: contextLoading } = useUser();
const translate = (code: string, metadata: any = {}, value?: any) => {
let template = t('import.errors.' + code);
if (template === 'import.errors.' + code) {
template = code; // Fallback
}
const merged = { ...metadata };
if (value !== undefined) {
merged.value = value;
}
for (const key of Object.keys(merged)) {
template = template.replace(`{${key}}`, String(merged[key]));
}
return template;
};
useEffect(() => {
if (!contextLoading) {
if (!currentUser) {
@ -73,7 +59,6 @@ export default function SalesImportPage() {
}, [contextLoading, currentUser, role, router]);
useEffect(() => {
// Generate unique idempotency key for this session/upload instance
const key = 'key-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
setIdempotencyKey(key);
}, []);
@ -84,7 +69,7 @@ export default function SalesImportPage() {
<Header activeTab="import" />
<main className={styles.main}>
<div className={styles.card} style={{ textAlign: 'center', padding: '2rem' }}>
<p>Cargando módulo de importación...</p>
<p>{t('import.loading')}</p>
</div>
</main>
</div>
@ -113,7 +98,7 @@ export default function SalesImportPage() {
setValidationErrors([]);
setSuccessData(null);
} else {
setGeneralError("Tipo de archivo no soportado. Cargue archivos .xlsx, .xls o .csv");
setGeneralError(t('import.unsupportedFile'));
}
}
};
@ -129,7 +114,7 @@ export default function SalesImportPage() {
};
const startPolling = (key: string) => {
setStatusMessage("Procesando integración asíncrona mediante n8n...");
setStatusMessage(t('import.statusPolling'));
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/sales/import/status/${key}`);
@ -154,12 +139,11 @@ export default function SalesImportPage() {
}
}, 2000);
// Safety timeout: stop polling after 30 seconds
setTimeout(() => {
clearInterval(interval);
if (isUploading) {
setIsUploading(false);
setGeneralError("El procesamiento por n8n está tomando más tiempo de lo esperado. Revise el historial más tarde.");
setGeneralError(t('import.timeout'));
}
}, 30000);
};
@ -190,22 +174,18 @@ export default function SalesImportPage() {
const data = await res.json();
if (res.status === 201) {
// Direct successful import
setProgress(100);
setIsUploading(false);
setSuccessData(data.metadata || { count: 0 });
} else if (res.status === 202) {
// Accepted (asynchronous processing via n8n)
setProgress(90);
startPolling(idempotencyKey);
} else if (res.status === 200 && data.code === 'IMPORT_ALREADY_PROCESSED') {
// Idempotency cached response
setProgress(100);
setIsUploading(false);
setSuccessData(data.metadata);
setGeneralError(translate(data.code, data.metadata));
} else {
// Error occurred
setIsUploading(false);
setProgress(0);
if (data.error?.code === 'IMPORT_VALIDATION_FAILED') {
@ -219,7 +199,7 @@ export default function SalesImportPage() {
} catch (err: any) {
setIsUploading(false);
setProgress(0);
setGeneralError("Fallo de red o error inesperado al subir el archivo.");
setGeneralError(t('import.networkError'));
}
};
@ -229,23 +209,22 @@ export default function SalesImportPage() {
<main className={styles.main}>
<div className={styles.card}>
<div className={styles.header}>
<h1 className={styles.title}>Cargar Ventas Comerciales</h1>
<p className={styles.subtitle}>Importe el archivo de resultados para procesar las comisiones del periodo.</p>
<h1 className={styles.title}>{t('import.title')}</h1>
<p className={styles.subtitle}>{t('import.subtitle')}</p>
</div>
<div className={styles.templateDownload}>
<span className={styles.templateLabel}>Utilice la plantilla oficial para evitar inconsistencias:</span>
<span className={styles.templateLabel}>{t('import.templateLabel')}</span>
<a
href="/templates/import_sales_template.xlsx"
download="import_sales_template.xlsx"
className={styles.downloadBtn}
id="btn-download-template"
>
Descargar Plantilla (.xlsx)
{t('import.downloadTemplate')}
</a>
</div>
{/* Drag & Drop Area */}
<div
className={`${styles.dropZone} ${dragActive ? styles.dropZoneActive : ''} ${file ? styles.dropZoneHasFile : ''}`}
onDragEnter={handleDrag}
@ -269,27 +248,25 @@ export default function SalesImportPage() {
</div>
) : (
<div>
<p className={styles.dropText}>Arrastre y suelte su archivo aquí, o <span className={styles.browseText}>explore archivos</span></p>
<p className={styles.supportedText}>Formatos permitidos: .xlsx, .xls, .csv</p>
<p className={styles.dropText}>{t('import.dragText')} <span className={styles.browseText}>{t('import.browseText')}</span></p>
<p className={styles.supportedText}>{t('import.supportedText')}</p>
</div>
)}
</label>
</div>
{/* Progress Bar */}
{isUploading && (
<div className={styles.progressContainer}>
<div className={styles.progressBarWrapper}>
<div className={styles.progressBar} style={{ width: `${progress}%` }}></div>
</div>
<div className={styles.progressText}>
<span>Subiendo y verificando archivo... {progress}%</span>
<span>{t('import.uploading')} {progress}%</span>
{statusMessage && <p className={styles.statusText}>{statusMessage}</p>}
</div>
</div>
)}
{/* Action buttons */}
<div className={styles.actions}>
<button
onClick={handleUpload}
@ -297,7 +274,7 @@ export default function SalesImportPage() {
className={styles.btnPrimary}
id="btn-submit-upload"
>
Procesar Archivo
{t('import.process')}
</button>
{file && !isUploading && (
<button
@ -305,52 +282,49 @@ export default function SalesImportPage() {
className={styles.btnSecondary}
id="btn-clear-file"
>
Limpiar
{t('import.clear')}
</button>
)}
</div>
{/* Successful Import Alert */}
{successData && (
<div className={styles.alertSuccess} id="upload-success-msg">
<span className={styles.alertIcon}></span>
<div>
<strong>Carga exitosa:</strong>
<p>Se importaron exitosamente {successData.count} registros de venta.</p>
{successData.totalAmount !== undefined && <p>Monto consolidado: ${successData.totalAmount.toLocaleString()}</p>}
<strong>{t('import.success')}</strong>
<p>{t('import.successDesc').replace('{count}', String(successData.count))}</p>
{successData.totalAmount !== undefined && <p>{t('import.consolidated')} ${successData.totalAmount.toLocaleString()}</p>}
</div>
</div>
)}
{/* General Error Alert */}
{generalError && (
<div className={styles.alertError} id="upload-error-msg">
<span className={styles.alertIcon}></span>
<div>
<strong>Inconsistencia detectada:</strong>
<strong>{t('import.inconsistencyAlert')}</strong>
<p>{generalError}</p>
</div>
</div>
)}
{/* Row-Level Inconsistency Panel */}
{validationErrors.length > 0 && (
<div className={styles.inconsistencyContainer} id="inconsistency-panel">
<h3 className={styles.inconsistencyTitle}>Detalle de Inconsistencias en las Filas</h3>
<h3 className={styles.inconsistencyTitle}>{t('import.inconsistencyTitle')}</h3>
<div className={styles.tableWrapper}>
<table className={styles.inconsistencyTable}>
<thead>
<tr>
<th>Fila</th>
<th>Columna</th>
<th>Valor Leído</th>
<th>Detalle del Error</th>
<th>{t('import.thRow')}</th>
<th>{t('import.thColumn')}</th>
<th>{t('import.thValue')}</th>
<th>{t('import.thError')}</th>
</tr>
</thead>
<tbody>
{validationErrors.map((err, idx) => (
<tr key={idx} className={styles.inconsistencyRow}>
<td className={styles.cellRow}>Fila {err.row}</td>
<td className={styles.cellRow}>{t('import.thRow')} {err.row}</td>
<td className={styles.cellCol}>{err.column}</td>
<td className={styles.cellVal}><code>{String(err.value || '')}</code></td>
<td className={styles.cellMsg}>{translate(err.code, err.metadata, err.value)}</td>

View file

@ -142,13 +142,13 @@ export default function SimulationPage() {
});
if (simulateOnly) {
setSuccess('Simulación completada con éxito. Los resultados no se han guardado.');
setSuccess(t('simulation.successSimulate'));
} else {
setSuccess('Liquidaciones calculadas y guardadas correctamente.');
setSuccess(t('simulation.successCalculate'));
}
} catch (err: any) {
console.error('Calculation fetch error:', err);
setError('Error al comunicarse con el servidor.');
setError(t('simulation.serverError'));
} finally {
setIsProcessing(false);
}
@ -159,7 +159,7 @@ export default function SimulationPage() {
<div className={styles.container}>
<Header activeTab="simulation" />
<div className={styles.mainContent}>
<p>Cargando panel de simulación...</p>
<p>{t('simulation.loading')}</p>
</div>
</div>
);
@ -172,9 +172,9 @@ export default function SimulationPage() {
<Header activeTab="simulation" />
<div className={styles.mainContent}>
<div className={styles.card}>
<h1 className={styles.title} style={{ color: '#f87171' }}>Acceso Restringido</h1>
<h1 className={styles.title} style={{ color: '#f87171' }}>{t('simulation.restrictedTitle')}</h1>
<p className={styles.description}>
Lo sentimos, esta sección es exclusiva para el Administrador y el Analista de Compensaciones.
{t('simulation.restrictedDesc')}
</p>
</div>
</div>

View file

@ -106,7 +106,7 @@ export default function ApprovalsPage() {
fetchSettlements();
} catch (err) {
console.error('Failed to approve settlement:', err);
setError('Error al comunicarse con el servidor.');
setError(t('approvals.errorServer'));
}
};
@ -141,16 +141,16 @@ export default function ApprovalsPage() {
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al rechazar la liquidación.');
setError(data.error || t('approvals.errorReject'));
setIsSubmittingReject(false);
return;
}
setSuccess('Liquidación rechazada.');
setSuccess(t('approvals.successReject'));
closeRejectModal();
fetchSettlements();
} catch (err) {
console.error('Failed to reject settlement:', err);
setError('Error al comunicarse con el servidor.');
setError(t('approvals.errorServer'));
} finally {
setIsSubmittingReject(false);
}
@ -161,7 +161,7 @@ export default function ApprovalsPage() {
<div className={styles.container}>
<Header activeTab="approvals" />
<div className={styles.mainContent}>
<p>Cargando panel de aprobaciones...</p>
<p>{t('approvals.loading')}</p>
</div>
</div>
);
@ -174,11 +174,11 @@ export default function ApprovalsPage() {
<Header activeTab="approvals" />
<main className={styles.mainContent}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Panel de Aprobaciones</h1>
<h1 className={styles.title}>{t('approvals.title')}</h1>
<p className={styles.description}>
{isLeader
? 'Revise y apruebe las liquidaciones de comisión de los colaboradores de su región.'
: 'Visualice y audite el estado de aprobación de las liquidaciones de comisiones.'}
? t('approvals.leaderDesc')
: t('approvals.globalDesc')}
</p>
</div>
@ -205,7 +205,7 @@ export default function ApprovalsPage() {
{settlements.length === 0 ? (
<tr>
<td colSpan={9} className={styles.td} style={{ textAlign: 'center' }}>
No se encontraron liquidaciones pendientes en su región.
{t('approvals.empty')}
</td>
</tr>
) : (
@ -265,14 +265,14 @@ export default function ApprovalsPage() {
className={styles.btnApprove}
onClick={() => handleApprove(s.id)}
>
Aprobar
{t('approvals.btnApprove')}
</button>
<button
id={`btn-reject-${s.id}`}
className={styles.btnReject}
onClick={() => openRejectModal(s.id)}
>
Rechazar
{t('approvals.btnReject')}
</button>
</div>
) : (
@ -292,7 +292,7 @@ export default function ApprovalsPage() {
{showRejectModal && (
<div className={styles.modalBackdrop}>
<div className={styles.modalContent}>
<h2 className={styles.modalTitle}>Rechazar Liquidación</h2>
<h2 className={styles.modalTitle}>{t('approvals.btnReject')} Liquidación</h2>
<textarea
id="reject-reason-input"
className={styles.textarea}
@ -308,7 +308,7 @@ export default function ApprovalsPage() {
onClick={closeRejectModal}
disabled={isSubmittingReject}
>
Cancelar
{t('approvals.rejectCancel')}
</button>
<button
id="btn-confirm-reject"
@ -316,7 +316,7 @@ export default function ApprovalsPage() {
onClick={handleRejectConfirm}
disabled={isSubmittingReject}
>
{isSubmittingReject ? 'Rechazando...' : 'Confirmar Rechazo'}
{isSubmittingReject ? 'Rechazando...' : t('approvals.rejectConfirm')}
</button>
</div>
</div>

View file

@ -31,7 +31,13 @@
"thAdjustment": "Retroactive Adjustments",
"thPayout": "Total Payout",
"thStatus": "Status",
"thAiAudit": "AI Audit"
"thAiAudit": "AI Audit",
"restrictedTitle": "Restricted Access",
"restrictedDesc": "Sorry, this section is exclusive to the Administrator and the Compensation Analyst.",
"successSimulate": "Simulation completed successfully. Results have not been saved.",
"successCalculate": "Settlements calculated and saved successfully.",
"serverError": "Error communicating with the server.",
"loading": "Loading simulation panel..."
},
"status": {
"ACTIVE": "Active",
@ -63,7 +69,8 @@
"trends": "Monthly Commission Trends",
"hotels_comparison": "Hotel Performance Comparison",
"trends_desc": "Commission Paid vs Budget Cap",
"hotels_desc": "Sales and achievements across hotels"
"hotels_desc": "Sales and achievements across hotels",
"loading": "Loading dashboard..."
},
"history": {
"title": "My Commission History",
@ -78,7 +85,11 @@
"empty": "No history records found.",
"filters": "Filters",
"all_statuses": "All Statuses",
"download_pdf": "Download PDF"
"download_pdf": "Download PDF",
"loading": "Loading history...",
"showNotes": "Show Notes",
"hideNotes": "Hide",
"allCollaborators": "All"
},
"audit": {
"title": "System Audit Logs",
@ -92,6 +103,176 @@
"diff_panel": "Side-by-Side JSON Diff Details",
"previous": "Previous Value",
"new": "New Value",
"no_diff": "No modification details available."
"no_diff": "No modification details available.",
"loading": "Loading logs..."
},
"login": {
"title": "Log In",
"subtitle": "Hoteles Estelar",
"username": "Username",
"password": "Password",
"usernamePlaceholder": "Enter your username",
"passwordPlaceholder": "Enter your password",
"submit": "Log In",
"footer": "Variable Remuneration & Commissions System",
"loading": "Loading form...",
"error": "Login failed. Please check your credentials.",
"unexpectedError": "An unexpected error occurred. Please try again."
},
"plans": {
"title": "Commission Plans",
"newPlan": "New Plan",
"loading": "Loading plans...",
"version": "Version",
"startValidity": "Start Validity",
"endValidity": "End Validity",
"rulesCreated": "Rules created",
"configureRules": "Configure Rules",
"viewRules": "View Rules",
"deactivateVersion": "Deactivate (Version)",
"activate": "Activate",
"modalTitle": "Create New Plan",
"modalName": "Plan Name",
"modalCode": "Plan Code",
"modalValidityStart": "Start Validity",
"modalType": "Remuneration Type",
"modalMetaAmount": "Sales Goal (Optional)",
"modalMaxCap": "Max Cap (Optional)",
"modalStatus": "Initial Status",
"modalCancel": "Cancel",
"modalSave": "Save Plan",
"types": {
"PERCENTAGE": "Simple Percentage",
"SCALE": "Contiguous Scale",
"CONDITIONAL": "Conditional by Goals",
"FIXED": "Fixed Commission"
},
"errorFind": "Could not find the plan.",
"errorLoad": "Error loading plan information."
},
"rules": {
"back": "Back to Plans",
"title": "Configure Commission Rules",
"subtitle": "Plan: {name} | Code: {code} | Version: {version} | Status: {status}",
"thType": "Rule Type",
"thMin": "Min Achievement (%)",
"thMax": "Max Achievement (%)",
"thRate": "Commission Rate (Decimal)",
"thPayout": "Fixed Payout ($)",
"addRule": "+ Add Bracket / Rule",
"cancel": "Cancel",
"backBtn": "Back",
"save": "Save Rules",
"loading": "Loading configuration...",
"delete": "Delete rule",
"types": {
"TIER": "Bracket (TIER)",
"BONUS": "Fixed Bonus (BONUS)"
},
"errorAtLeastOne": "The plan must have at least one rule.",
"errorBoundary": "Row {row}: Min achievement ({min}) must be less than max achievement ({max}).",
"errorNegative": "Row {row}: All values must be greater than or equal to zero.",
"errorSave": "Error saving rules.",
"errorNetwork": "A network error occurred while saving rules.",
"successSave": "Rules configured and saved successfully."
},
"goals": {
"title": "Commercial Goals",
"assignTitle": "Assign Goal",
"labelType": "Goal Type",
"labelSelect": "Select Collaborator",
"labelPeriod": "Period (YYYY-MM)",
"labelAmount": "Goal Amount ($)",
"btnSave": "Save Goal",
"thTarget": "Target",
"thType": "Type",
"thPeriod": "Period",
"thAmount": "Amount",
"thActions": "Actions",
"empty": "No assigned goals found.",
"loading": "Loading goals...",
"successSave": "Commercial goal assigned / updated successfully.",
"errorSave": "An error occurred while saving the goal.",
"errorInput": "Please enter valid values.",
"roles": {
"collaborator": "Collaborator",
"commercial_leader": "Commercial Leader",
"hotel_manager": "Manager",
"director": "Director",
"analyst": "Analyst",
"admin": "Administrator"
}
},
"import": {
"title": "Upload Commercial Sales",
"subtitle": "Import the results file to process period commissions.",
"templateLabel": "Use the official template to avoid inconsistencies:",
"downloadTemplate": "Download Template (.xlsx)",
"loading": "Loading import module...",
"unsupportedFile": "Unsupported file type. Please upload .xlsx, .xls or .csv files",
"uploading": "Uploading and verifying file...",
"process": "Process File",
"clear": "Clear",
"success": "Upload successful:",
"successDesc": "Successfully imported {count} sales records.",
"consolidated": "Consolidated amount:",
"inconsistencyTitle": "Inconsistency Row Details",
"thRow": "Row",
"thColumn": "Column",
"thValue": "Read Value",
"thError": "Error Detail",
"statusPolling": "Processing async integration via n8n...",
"timeout": "n8n processing is taking longer than expected. Please check history later.",
"networkError": "Network failure or unexpected error while uploading file.",
"dragText": "Drag and drop your file here, or",
"browseText": "browse files",
"supportedText": "Supported formats: .xlsx, .xls, .csv",
"inconsistencyAlert": "Inconsistency detected:",
"errors": {
"USER_NOT_FOUND": "Collaborator '{username}' does not exist in the system.",
"INVALID_PERIOD_FORMAT": "Period '{value}' has an invalid format (expected: {expected}).",
"HOTEL_NOT_FOUND": "Hotel '{hotelCode}' does not exist in the system.",
"HOTEL_REGION_MISMATCH": "Hotel '{hotelCode}' does not belong to your authorized region.",
"INVALID_AMOUNT": "Amount '{value}' is invalid. It must be a positive number.",
"INVALID_COUNT": "Quantity '{value}' is invalid. It must be a positive integer.",
"USER_REQUIRED": "Collaborator is required.",
"HOTEL_REQUIRED": "Hotel is required.",
"IMPORT_VALIDATION_FAILED": "The uploaded file contains validation inconsistencies.",
"MISSING_IDEMPOTENCY_KEY": "Idempotency key header is required.",
"FILE_REQUIRED": "Must select a valid file.",
"EMPTY_FILE": "The file is empty and contains no records.",
"INTEGRATION_ERROR": "The integration engine (n8n) reported a failure while processing the request.",
"IMPORT_ALREADY_PROCESSED": "This file has already been successfully uploaded and processed before.",
"IMPORT_SUCCESSFUL": "Import completed successfully. Loaded {count} records.",
"IMPORT_ACCEPTED": "The import has been accepted and is being processed by n8n in the background."
}
},
"approvals": {
"title": "Approvals Panel",
"loading": "Loading approvals panel...",
"leaderDesc": "Review and approve commission settlements for collaborators in your region.",
"globalDesc": "View and audit the approval status of commission settlements.",
"empty": "No pending settlements found in your region.",
"thCollaborator": "Collaborator",
"thPlan": "Plan",
"thPeriod": "Period",
"thGoal": "Goal",
"thSales": "Sales",
"thAchievement": "Achievement",
"thPayout": "Total Payout",
"thStatus": "Status",
"thActions": "Actions",
"btnApprove": "Approve",
"btnReject": "Reject",
"rejectModalTitle": "Reject Commission Settlement",
"rejectReasonLabel": "Reason for Rejection",
"rejectReasonPlaceholder": "e.g., Missing physical sales proof validation...",
"rejectCancel": "Cancel",
"rejectConfirm": "Confirm Rejection",
"successApprove": "Settlement approved successfully.",
"successReject": "Settlement rejected.",
"errorApprove": "Error approving settlement.",
"errorReject": "Error rejecting settlement.",
"errorServer": "Error communicating with the server."
}
}
}

View file

@ -31,7 +31,13 @@
"thAdjustment": "Ajustes Retroactivos",
"thPayout": "Pago Total",
"thStatus": "Estado",
"thAiAudit": "Auditoría AI"
"thAiAudit": "Auditoría IA",
"restrictedTitle": "Acceso Restringido",
"restrictedDesc": "Lo sentimos, esta sección es exclusiva para el Administrador y el Analista de Compensaciones.",
"successSimulate": "Simulación completada con éxito. Los resultados no se han guardado.",
"successCalculate": "Liquidaciones calculadas y guardadas correctamente.",
"serverError": "Error al comunicarse con el servidor.",
"loading": "Cargando panel de simulación..."
},
"status": {
"ACTIVE": "Activo",
@ -63,7 +69,8 @@
"trends": "Tendencias de Comisiones Mensuales",
"hotels_comparison": "Comparación de Rendimiento de Hoteles",
"trends_desc": "Comisión Pagada vs Límite de Presupuesto",
"hotels_desc": "Ventas y logros por hotel"
"hotels_desc": "Ventas y logros por hotel",
"loading": "Cargando panel de control..."
},
"history": {
"title": "Mi Historial de Comisiones",
@ -78,7 +85,11 @@
"empty": "No se encontraron registros de historial.",
"filters": "Filtros",
"all_statuses": "Todos los Estados",
"download_pdf": "Descargar PDF"
"download_pdf": "Descargar PDF",
"loading": "Cargando historial...",
"showNotes": "Ver Notas",
"hideNotes": "Ocultar",
"allCollaborators": "Todos"
},
"audit": {
"title": "Registros de Auditoría del Sistema",
@ -92,6 +103,176 @@
"diff_panel": "Detalles del JSON Diff Lado a Lado",
"previous": "Valor Anterior",
"new": "Valor Nuevo",
"no_diff": "No hay detalles de modificación disponibles."
"no_diff": "No hay detalles de modificación disponibles.",
"loading": "Cargando registros..."
},
"login": {
"title": "Iniciar Sesión",
"subtitle": "Hoteles Estelar",
"username": "Usuario",
"password": "Contraseña",
"usernamePlaceholder": "Ingrese su usuario",
"passwordPlaceholder": "Ingrese su contraseña",
"submit": "Ingresar",
"footer": "Sistema de Remuneración Variable y Comisiones",
"loading": "Cargando formulario...",
"error": "Error al iniciar sesión. Verifique sus credenciales.",
"unexpectedError": "Ocurrió un error inesperado. Por favor, intente nuevamente."
},
"plans": {
"title": "Planes de Comisión",
"newPlan": "Nuevo Plan",
"loading": "Cargando planes...",
"version": "Versión",
"startValidity": "Inicio Vigencia",
"endValidity": "Fin Vigencia",
"rulesCreated": "Reglas creadas",
"configureRules": "Configurar Reglas",
"viewRules": "Ver Reglas",
"deactivateVersion": "Inactivar (Versión)",
"activate": "Activar",
"modalTitle": "Crear Nuevo Plan",
"modalName": "Nombre del Plan",
"modalCode": "Código del Plan",
"modalValidityStart": "Inicio de Vigencia",
"modalType": "Tipo de Remuneración",
"modalMetaAmount": "Meta de Ventas (Opcional)",
"modalMaxCap": "Tope Máximo / Cap (Opcional)",
"modalStatus": "Estado Inicial",
"modalCancel": "Cancelar",
"modalSave": "Guardar Plan",
"types": {
"PERCENTAGE": "Porcentaje Simple",
"SCALE": "Escala Contigua",
"CONDITIONAL": "Condicional por Metas",
"FIXED": "Comisión Fija"
},
"errorFind": "No se pudo encontrar el plan.",
"errorLoad": "Error al cargar la información del plan."
},
"rules": {
"back": "Volver a Planes",
"title": "Configurar Reglas de Comisión",
"subtitle": "Plan: {name} | Código: {code} | Versión: {version} | Estado: {status}",
"thType": "Tipo de Regla",
"thMin": "Min Logro (%)",
"thMax": "Max Logro (%)",
"thRate": "Tasa Comisión (Decimal)",
"thPayout": "Payout Fijo ($)",
"addRule": "+ Agregar Rango / Regla",
"cancel": "Cancelar",
"backBtn": "Volver",
"save": "Guardar Reglas",
"loading": "Cargando configuración...",
"delete": "Eliminar regla",
"types": {
"TIER": "Rango (TIER)",
"BONUS": "Bono Fijo (BONUS)"
},
"errorAtLeastOne": "El plan debe tener al menos una regla.",
"errorBoundary": "Fila {row}: El logro mínimo ({min}) debe ser menor que el logro máximo ({max}).",
"errorNegative": "Fila {row}: Todos los valores deben ser mayores o iguales a cero.",
"errorSave": "Error al guardar las reglas.",
"errorNetwork": "Ocurrió un error de red al guardar las reglas.",
"successSave": "Reglas configuradas y guardadas exitosamente."
},
"goals": {
"title": "Metas Comerciales",
"assignTitle": "Asignar Meta",
"labelType": "Tipo de Meta",
"labelSelect": "Seleccionar Colaborador",
"labelPeriod": "Período (YYYY-MM)",
"labelAmount": "Monto de Meta ($)",
"btnSave": "Guardar Meta",
"thTarget": "Destino",
"thType": "Tipo",
"thPeriod": "Período",
"thAmount": "Monto",
"thActions": "Acciones",
"empty": "No se encontraron metas asignadas.",
"loading": "Cargando metas...",
"successSave": "Meta comercial asignada / actualizada exitosamente.",
"errorSave": "Ocurrió un error al guardar la meta.",
"errorInput": "Por favor, ingrese valores válidos.",
"roles": {
"collaborator": "Colaborador",
"commercial_leader": "Líder Comercial",
"hotel_manager": "Gerente",
"director": "Director",
"analyst": "Analista",
"admin": "Administrador"
}
},
"import": {
"title": "Cargar Ventas Comerciales",
"subtitle": "Importe el archivo de resultados para procesar las comisiones del periodo.",
"templateLabel": "Utilice la plantilla oficial para evitar inconsistencias:",
"downloadTemplate": "Descargar Plantilla (.xlsx)",
"loading": "Cargando módulo de importación...",
"unsupportedFile": "Tipo de archivo no soportado. Cargue archivos .xlsx, .xls o .csv",
"uploading": "Subiendo y verificando archivo...",
"process": "Procesar Archivo",
"clear": "Limpiar",
"success": "Carga exitosa:",
"successDesc": "Se importaron exitosamente {count} registros de venta.",
"consolidated": "Monto consolidado:",
"inconsistencyTitle": "Detalle de Inconsistencias en las Filas",
"thRow": "Fila",
"thColumn": "Columna",
"thValue": "Valor Leído",
"thError": "Detalle del Error",
"statusPolling": "Procesando integración asíncrona mediante n8n...",
"timeout": "El procesamiento por n8n está tomando más tiempo de lo esperado. Revise el historial más tarde.",
"networkError": "Fallo de red o error inesperado al subir el archivo.",
"dragText": "Arrastre y suelte su archivo aquí, o",
"browseText": "explore archivos",
"supportedText": "Formatos permitidos: .xlsx, .xls, .csv",
"inconsistencyAlert": "Inconsistencia detectada:",
"errors": {
"USER_NOT_FOUND": "El colaborador '{username}' no existe en el sistema.",
"INVALID_PERIOD_FORMAT": "El período '{value}' no tiene formato válido (esperado: {expected}).",
"HOTEL_NOT_FOUND": "El hotel '{hotelCode}' no existe en el sistema.",
"HOTEL_REGION_MISMATCH": "El hotel '{hotelCode}' no pertenece a su región autorizada.",
"INVALID_AMOUNT": "El monto '{value}' no es válido. Debe ser un número positivo.",
"INVALID_COUNT": "La cantidad '{value}' no es válida. Debe ser un número entero positivo.",
"USER_REQUIRED": "El colaborador es obligatorio.",
"HOTEL_REQUIRED": "El hotel es obligatorio.",
"IMPORT_VALIDATION_FAILED": "El archivo cargado contiene inconsistencias de validación.",
"MISSING_IDEMPOTENCY_KEY": "El encabezado de idempotencia es obligatorio.",
"FILE_REQUIRED": "Debe seleccionar un archivo válido.",
"EMPTY_FILE": "El archivo está vacío y no contiene registros.",
"INTEGRATION_ERROR": "El motor de integraciones (n8n) reportó un fallo al procesar la solicitud.",
"IMPORT_ALREADY_PROCESSED": "Este archivo ya fue cargado y procesado con éxito anteriormente.",
"IMPORT_SUCCESSFUL": "Importación completada con éxito. Se cargaron {count} registros.",
"IMPORT_ACCEPTED": "La importación ha sido aceptada y se está procesando mediante n8n en segundo plano."
}
},
"approvals": {
"title": "Panel de Aprobaciones",
"loading": "Cargando panel de aprobaciones...",
"leaderDesc": "Revise y apruebe las liquidaciones de comisión de los colaboradores de su región.",
"globalDesc": "Visualice y audite el estado de aprobación de las liquidaciones de comisiones.",
"empty": "No se encontraron liquidaciones pendientes en su región.",
"thCollaborator": "Colaborador",
"thPlan": "Plan",
"thPeriod": "Período",
"thGoal": "Meta",
"thSales": "Ventas",
"thAchievement": "Cumplimiento",
"thPayout": "Pago Total",
"thStatus": "Estado",
"thActions": "Acciones",
"btnApprove": "Aprobar",
"btnReject": "Rechazar",
"rejectModalTitle": "Rechazar Liquidación de Comisión",
"rejectReasonLabel": "Motivo del Rechazo",
"rejectReasonPlaceholder": "Ej. Falta validar soporte físico de ventas...",
"rejectCancel": "Cancelar",
"rejectConfirm": "Confirmar Rechazo",
"successApprove": "Liquidación aprobada con éxito.",
"successReject": "Liquidación rechazada.",
"errorApprove": "Error al aprobar la liquidación.",
"errorReject": "Error al rechazar la liquidación.",
"errorServer": "Error al comunicarse con el servidor."
}
}
}