i18n: enforce strict localization consistency across all pages and components
This commit is contained in:
parent
086a37f43c
commit
466dd0e59c
12 changed files with 524 additions and 188 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
← Volver a Planes
|
||||
← {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')}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue