Merge pull request 'fix/idioma-bitacota' (#296) from fix/idioma-bitacota into development

Reviewed-on: ADUANASOFT/anexo76#296
This commit is contained in:
2026-04-22 22:52:53 +00:00
3 changed files with 356 additions and 78 deletions

View File

@@ -0,0 +1,252 @@
import { getLocale } from '$lib/paraglide/runtime';
type SupportedLocale = 'en' | 'es';
type LocalizedText = Record<SupportedLocale, string>;
const PROCEDURE_TRANSLATIONS: Record<string, LocalizedText> = {
'IMPORT INVOICE BROWSE': { en: 'Import invoice lookup', es: 'Consulta de factura de importación' },
'IMPORT INVOICE UPDATE': { en: 'Import invoice update', es: 'Actualización de factura de importación' },
'PEDIMENTO BROWSE': { en: 'Pedimento lookup', es: 'Consulta de pedimento' },
'SYSTEM SCAF': { en: 'SCAF system', es: 'Sistema SCAF' },
'SYSTEM AUTH': { en: 'System authentication', es: 'Autenticación del sistema' },
CATALOGS: { en: 'Catalogs', es: 'Catálogos' },
'ELECTRONIC NOTICES': { en: 'Electronic notices', es: 'Avisos electrónicos' },
DODA: { en: 'DODA', es: 'DODA' }
};
const MOVEMENT_TRANSLATIONS: Record<string, LocalizedText> = {
'ADD IMPORT_INVOICE': { en: 'Add import invoice', es: 'Agregar factura de importación' },
'EDIT IMPORT_INVOICE': { en: 'Edit import invoice', es: 'Editar factura de importación' },
'DELETE IMPORT_INVOICE': { en: 'Delete import invoice', es: 'Eliminar factura de importación' },
'ADD IMPORT_INVOICE_ITEM': { en: 'Add import invoice item', es: 'Agregar partida de factura de importación' },
'EDIT IMPORT_INVOICE_ITEM': { en: 'Edit import invoice item', es: 'Editar partida de factura de importación' },
'DELETE IMPORT_INVOICE_ITEM': { en: 'Delete import invoice item', es: 'Eliminar partida de factura de importación' },
'ADD PEDIMENTO': { en: 'Add pedimento', es: 'Agregar pedimento' },
'EDIT PEDIMENTO': { en: 'Edit pedimento', es: 'Editar pedimento' },
'DELETE PEDIMENTO': { en: 'Delete pedimento', es: 'Eliminar pedimento' },
'SYSTEM LOGIN': { en: 'System login', es: 'Inicio de sesión del sistema' },
'SYSTEM LOGOUT': { en: 'System logout', es: 'Cierre de sesión del sistema' },
'ADD CLASS': { en: 'Add class', es: 'Agregar clase' },
'EDIT CLASS': { en: 'Edit class', es: 'Editar clase' },
'DELETE CLASS': { en: 'Delete class', es: 'Eliminar clase' },
'ADD DODA': { en: 'Add DODA', es: 'Agregar DODA' },
'EDIT DODA': { en: 'Edit DODA', es: 'Editar DODA' },
'DELETE DODA': { en: 'Delete DODA', es: 'Eliminar DODA' },
'ADD COMPANY': { en: 'Add company', es: 'Agregar empresa' },
'EDIT COMPANY': { en: 'Edit company', es: 'Editar empresa' },
'DELETE COMPANY': { en: 'Delete company', es: 'Eliminar empresa' }
};
const ENTITY_TRANSLATIONS: Record<string, LocalizedText> = {
'IMPORT INVOICE ITEM': { en: 'import invoice item', es: 'partida de factura de importación' },
'IMPORT INVOICE': { en: 'import invoice', es: 'factura de importación' },
'UNIT CONVERSIONS': { en: 'unit conversions', es: 'conversiones de unidades' },
UNITS: { en: 'units', es: 'unidades' },
CONVERSIONS: { en: 'conversions', es: 'conversiones' },
PEDIMENTO: { en: 'pedimento', es: 'pedimento' },
CLASS: { en: 'class', es: 'clase' },
COMPANY: { en: 'company', es: 'empresa' },
USER: { en: 'user', es: 'usuario' },
USERS: { en: 'users', es: 'usuarios' },
SESSION: { en: 'session', es: 'sesión' },
SESSIONS: { en: 'sessions', es: 'sesiones' },
ITEM: { en: 'item', es: 'artículo' },
ITEMS: { en: 'items', es: 'artículos' },
CLIENT: { en: 'client', es: 'cliente' },
CLIENTS: { en: 'clients', es: 'clientes' },
PROVIDER: { en: 'provider', es: 'proveedor' },
PROVIDERS: { en: 'providers', es: 'proveedores' },
CATALOGS: { en: 'catalogs', es: 'catálogos' },
'ELECTRONIC NOTICES': { en: 'electronic notices', es: 'avisos electrónicos' },
DODA: { en: 'DODA', es: 'DODA' },
'SYSTEM SCAF': { en: 'SCAF system', es: 'sistema SCAF' },
'SYSTEM AUTH': { en: 'system authentication', es: 'autenticación del sistema' }
};
const VERB_TRANSLATIONS: Record<string, LocalizedText> = {
ADD: { en: 'Add', es: 'Agregar' },
EDIT: { en: 'Edit', es: 'Editar' },
DELETE: { en: 'Delete', es: 'Eliminar' },
CREATE: { en: 'Create', es: 'Crear' },
UPDATE: { en: 'Update', es: 'Actualizar' }
};
const TOKEN_TRANSLATIONS: Record<string, LocalizedText> = {
IMPORT: { en: 'import', es: 'importación' },
INVOICE: { en: 'invoice', es: 'factura' },
ITEM: { en: 'item', es: 'artículo' },
ITEMS: { en: 'items', es: 'artículos' },
PEDIMENTO: { en: 'pedimento', es: 'pedimento' },
SYSTEM: { en: 'system', es: 'sistema' },
AUTH: { en: 'authentication', es: 'autenticación' },
CATALOGS: { en: 'catalogs', es: 'catálogos' },
ELECTRONIC: { en: 'electronic', es: 'electrónicos' },
NOTICES: { en: 'notices', es: 'avisos' },
UNIT: { en: 'unit', es: 'unidad' },
UNITS: { en: 'units', es: 'unidades' },
CONVERSIONS: { en: 'conversions', es: 'conversiones' },
COMPANY: { en: 'company', es: 'empresa' },
CLASS: { en: 'class', es: 'clase' },
USER: { en: 'user', es: 'usuario' },
USERS: { en: 'users', es: 'usuarios' },
SESSION: { en: 'session', es: 'sesión' },
SESSIONS: { en: 'sessions', es: 'sesiones' },
CLIENT: { en: 'client', es: 'cliente' },
CLIENTS: { en: 'clients', es: 'clientes' },
PROVIDER: { en: 'provider', es: 'proveedor' },
PROVIDERS: { en: 'providers', es: 'proveedores' }
};
function normalizeKey(value: string) {
return value.trim().replace(/_/g, ' ').replace(/\s+/g, ' ').toUpperCase();
}
function currentLocale(): SupportedLocale {
return getLocale() === 'es' ? 'es' : 'en';
}
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function translateEntity(value: string) {
const locale = currentLocale();
const normalized = normalizeKey(value);
const exact = ENTITY_TRANSLATIONS[normalized];
if (exact) return exact[locale];
return normalized
.split(' ')
.map((token) => TOKEN_TRANSLATIONS[token]?.[locale] ?? token.toLowerCase())
.join(' ');
}
export function translateAuditProcedure(value: string) {
if (!value) return value;
const locale = currentLocale();
const normalized = normalizeKey(value);
const exact = PROCEDURE_TRANSLATIONS[normalized];
if (exact) return exact[locale];
if (normalized.endsWith(' BROWSE')) {
const entity = translateEntity(normalized.slice(0, -' BROWSE'.length));
return locale === 'es' ? `Consulta de ${entity}` : `${capitalize(entity)} lookup`;
}
if (normalized.endsWith(' UPDATE')) {
const entity = translateEntity(normalized.slice(0, -' UPDATE'.length));
return locale === 'es' ? `Actualización de ${entity}` : `${capitalize(entity)} update`;
}
const fallback = translateEntity(normalized);
return capitalize(fallback);
}
export function translateAuditMovement(value: string) {
if (!value) return value;
const locale = currentLocale();
const normalized = normalizeKey(value);
const exact = MOVEMENT_TRANSLATIONS[normalized];
if (exact) return exact[locale];
const [verb, ...rest] = normalized.split(' ');
const translatedVerb = VERB_TRANSLATIONS[verb];
if (!translatedVerb || rest.length === 0) {
return capitalize(translateEntity(normalized));
}
return `${translatedVerb[locale]} ${translateEntity(rest.join(' '))}`;
}
// ── Tareas en segundo plano (Celery) ──────────────────────────────────────
const TASK_GROUP_TRANSLATIONS: Record<string, LocalizedText> = {
factura_cove: { en: 'Invoice COVE', es: 'COVE de factura' },
layouts_csv: { en: 'CSV import', es: 'Importación CSV' },
reports: { en: 'Reports', es: 'Reportes' },
invoices: { en: 'Invoices', es: 'Facturas' }
};
const TASK_NAME_TRANSLATIONS: Record<string, LocalizedText> = {
// invoices
process_invoice_task: { en: 'Process import invoice', es: 'Procesar factura de importación' },
revert_import_invoice_task: { en: 'Revert import invoice', es: 'Revertir factura de importación' },
revert_invoice_task: { en: 'Revert import invoice', es: 'Revertir factura de importación' },
process_export_invoice_task: { en: 'Process export invoice', es: 'Procesar factura de exportación' },
revert_export_invoice_task: { en: 'Revert export invoice', es: 'Revertir factura de exportación' },
// factura_cove
factura_cove_generate: { en: 'Generate invoice COVE', es: 'Generar COVE de factura' },
// reports
generate_invoice_movements_async: { en: 'Generate invoice movements', es: 'Generar movimientos de facturas' },
generate_saldos_temporales_async: { en: 'Generate temporary balances', es: 'Generar saldos temporales' },
generate_vencimiento_csv_async: { en: 'Generate expiration CSV', es: 'Generar CSV de vencimiento' },
generar_packing_list_async: { en: 'Generate packing list', es: 'Generar packing list' },
generar_pdf_consolidado_async: { en: 'Generate consolidated PDF', es: 'Generar PDF consolidado' },
generar_pdf_factura_async: { en: 'Generate invoice PDF', es: 'Generar PDF de factura' },
generate_pedimentos_winsaai_task: { en: 'Generate WinSAAI pedimentos', es: 'Generar pedimentos WinSAAI' },
generar_transmission_definitiva_async: { en: 'Generate definitive transmission', es: 'Generar transmisión definitiva' },
generar_transmission_temporal_async: { en: 'Generate temporary transmission', es: 'Generar transmisión temporal' },
generate_descarga_pdf_task: { en: 'Generate discharge PDF', es: 'Generar PDF de descarga' },
generate_winsaai_task: { en: 'Generate WinSAAI file', es: 'Generar archivo WinSAAI' },
generar_pdf_aviso_consolidado_exp_async: { en: 'Generate export consolidated notice PDF', es: 'Generar PDF aviso consolidado exportación' },
generar_transmission_file_async: { en: 'Generate transmission file', es: 'Generar archivo de transmisión' },
// layouts_csv
vehicles_scan_file: { en: 'Scan vehicles CSV file', es: 'Escanear archivo de vehículos CSV' },
classes_scan_file: { en: 'Scan classes CSV file', es: 'Escanear archivo de clases CSV' },
classes_insert_valid_rows: { en: 'Import valid classes CSV rows', es: 'Importar clases válidas CSV' },
customs_brokers_scan_file: { en: 'Scan customs brokers CSV file', es: 'Escanear archivo de agentes aduanales CSV' },
customs_brokers_insert_valid_rows: { en: 'Import valid customs brokers CSV rows', es: 'Importar agentes aduanales válidos CSV' },
trailers_scan_file: { en: 'Scan trailers CSV file', es: 'Escanear archivo de remolques CSV' },
exchange_rate_scan_file: { en: 'Scan exchange rate CSV file', es: 'Escanear archivo de tipo de cambio CSV' },
exchange_rate_insert_valid_rows: { en: 'Import valid exchange rate CSV rows', es: 'Importar tipo de cambio válido CSV' },
exportacion_scan_file: { en: 'Scan export CSV file', es: 'Escanear archivo de exportación CSV' },
exportacion_insert_valid_rows: { en: 'Import valid export CSV rows', es: 'Importar exportación válida CSV' },
boms_scan_file: { en: 'Scan BOM CSV file', es: 'Escanear archivo de listas de materiales CSV' },
boms_insert_valid_rows: { en: 'Import valid BOM CSV rows', es: 'Importar listas de materiales válidas CSV' },
clients_and_providers_scan_file: { en: 'Scan clients and providers CSV file', es: 'Escanear archivo de clientes y proveedores CSV' },
clients_and_providers_insert_valid_rows: { en: 'Import valid clients and providers CSV rows', es: 'Importar clientes y proveedores válidos CSV' },
pedmientos_scan_file: { en: 'Scan pedimentos CSV file', es: 'Escanear archivo de pedimentos CSV' },
pedmientos_insert_valid_rows: { en: 'Import valid pedimentos CSV rows', es: 'Importar pedimentos válidos CSV' },
us_tariff_fractions_scan_file: { en: 'Scan US tariff fractions CSV file', es: 'Escanear archivo de fracciones arancelarias EUA CSV' },
us_tariff_fractions_insert_valid_rows: { en: 'Import valid US tariff fractions CSV rows', es: 'Importar fracciones arancelarias EUA válidas CSV' },
parts_scan_file: { en: 'Scan parts CSV file', es: 'Escanear archivo de partes CSV' },
parts_insert_valid_rows: { en: 'Import valid parts CSV rows', es: 'Importar partes válidas CSV' },
cambio_regimen_regularizacion_scan_file: { en: 'Scan regime change CSV file', es: 'Escanear archivo de cambio de régimen CSV' },
cambio_regimen_regularizacion_insert_valid_rows: { en: 'Import valid regime change CSV rows', es: 'Importar cambio de régimen válido CSV' },
facturas_scan_file: { en: 'Scan invoices CSV file', es: 'Escanear archivo de facturas CSV' },
facturas_insert_valid_rows: { en: 'Import valid invoices CSV rows', es: 'Importar facturas válidas CSV' },
transportistas_scan_file: { en: 'Scan carriers CSV file', es: 'Escanear archivo de transportistas CSV' },
cleanup_orphan_layout_imports: { en: 'Clean orphan CSV imports', es: 'Limpiar importaciones CSV huérfanas' },
// expediente archivos
expediente_archivos_digitalizar: { en: 'Digitize expediente file', es: 'Digitalizar archivo de expediente' }
};
const CELERY_STATE_TRANSLATIONS: Record<string, LocalizedText> = {
SUCCESS: { en: 'Succeeded', es: 'Exitoso' },
FAILURE: { en: 'Failed', es: 'Fallido' },
PENDING: { en: 'Pending', es: 'Pendiente' },
STARTED: { en: 'Started', es: 'Iniciado' },
RETRY: { en: 'Retrying', es: 'Reintentando' },
REVOKED: { en: 'Revoked', es: 'Cancelado' },
PROGRESS: { en: 'In progress', es: 'En progreso' }
};
export function translateTaskGroup(value: string): string {
if (!value) return value;
const locale = currentLocale();
return TASK_GROUP_TRANSLATIONS[value]?.[locale] ?? value;
}
export function translateTaskName(value: string): string {
if (!value) return value;
const locale = currentLocale();
return TASK_NAME_TRANSLATIONS[value]?.[locale] ?? value.replace(/_/g, ' ');
}
export function translateCeleryState(value: string): string {
if (!value) return value;
const locale = currentLocale();
return CELERY_STATE_TRANSLATIONS[value.toUpperCase()]?.[locale] ?? value;
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { getLocale } from '$lib/paraglide/runtime';
import {
AuditLogAPI,
type AuditLog,
@@ -9,6 +10,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import { translateAuditMovement, translateAuditProcedure } from '$lib/utils/audit-log-i18n';
import { RefreshCw, Search } from 'lucide-svelte';
let logs: AuditLog[] = [];
@@ -29,6 +31,14 @@
let hasMore = true;
let sentinel: HTMLElement;
function isSpanish() {
return getLocale() === 'es';
}
function text(es: string, en: string) {
return isSpanish() ? es : en;
}
async function loadLogs() {
if (loading) return;
loading = true;
@@ -98,25 +108,31 @@
}
function formatDate(dateStr: string, timestamp?: string): string {
const locale = isSpanish() ? 'es-MX' : 'en-US';
if (timestamp) {
return new Date(timestamp).toLocaleDateString();
return new Date(timestamp).toLocaleDateString(locale);
}
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${d}/${m}/${y}`;
return isSpanish() ? `${d}/${m}/${y}` : `${m}/${d}/${y}`;
}
function formatTime(timeStr: string, timestamp?: string): string {
const locale = isSpanish() ? 'es-MX' : 'en-US';
if (timestamp) {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return new Date(timestamp).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
if (!timeStr) return '';
try {
const [h, m] = timeStr.split(':');
let hour = parseInt(h, 10);
const ampm = hour >= 12 ? 'PM' : 'AM';
hour = hour % 12;
hour = hour ? hour : 12;
const rawHour = parseInt(h, 10);
if (isSpanish()) {
const date = new Date();
date.setHours(rawHour, parseInt(m, 10), 0, 0);
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
const hour = rawHour % 12 || 12;
const ampm = rawHour >= 12 ? 'PM' : 'AM';
return `${hour.toString().padStart(2, '0')}:${m} ${ampm}`;
} catch {
return timeStr;
@@ -156,15 +172,15 @@
<Card.Header class="flex-none py-3">
<div class="flex items-center justify-between">
<div>
<Card.Title>Bitácora de Auditoría</Card.Title>
<Card.Description class="text-xs">Mostrando {logs.length} de {total} registros</Card.Description>
<Card.Title>{text('Bitácora de Auditoría', 'Audit Log')}</Card.Title>
<Card.Description class="text-xs">{text(`Mostrando ${logs.length} de ${total} registros`, `Showing ${logs.length} of ${total} records`)}</Card.Description>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Ref, Mov, Usuario..."
placeholder={text('Ref, Mov, Usuario...', 'Ref, Move, User...')}
class="h-9 w-48 pl-8"
bind:value={search}
oninput={handleSearchInput}
@@ -172,7 +188,7 @@
</div>
<Input
id="username"
placeholder="Usuario"
placeholder={text('Usuario', 'User')}
class="h-9 w-32"
bind:value={usernameFilter}
oninput={handleSearchInput}
@@ -182,11 +198,11 @@
bind:value={procedureFilter}
onchange={handleFilterChange}
class="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
title="Procedimiento"
title={text('Procedimiento', 'Procedure')}
>
<option value="">Procedimiento: Todos</option>
<option value="">{text('Procedimiento: Todos', 'Procedure: All')}</option>
{#each procedures as proc}
<option value={proc}>{proc}</option>
<option value={proc}>{translateAuditProcedure(proc)}</option>
{/each}
</select>
<Input
@@ -195,7 +211,7 @@
class="h-9 w-36"
bind:value={dateFrom}
onchange={handleFilterChange}
title="Desde"
title={text('Desde', 'From')}
/>
<span class="text-sm text-muted-foreground">-</span>
<Input
@@ -204,10 +220,10 @@
class="h-9 w-36"
bind:value={dateTo}
onchange={handleFilterChange}
title="Hasta"
title={text('Hasta', 'To')}
/>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
Limpiar
{text('Limpiar', 'Clear')}
</Button>
<Button
variant="outline"
@@ -222,7 +238,7 @@
disabled={loading}
>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
{text('Actualizar', 'Refresh')}
</Button>
</div>
</div>
@@ -230,7 +246,7 @@
<Card.Content class="flex min-h-0 flex-1 flex-col p-0 px-6 pb-6">
{#if error}
<div class="rounded-md bg-red-50 p-4 text-center text-red-500 dark:bg-red-950/30 dark:text-red-400">
Error al cargar datos: {error}
{text('Error al cargar datos', 'Error loading data')}: {error}
</div>
{:else}
<div class="relative min-h-0 flex-1 overflow-y-auto rounded-md border bg-card shadow-inner">
@@ -238,25 +254,25 @@
<Table.Header class="sticky top-0 z-10 bg-background/95 shadow-sm backdrop-blur-sm">
<Table.Row>
<Table.Head class="w-[80px]">ID</Table.Head>
<Table.Head class="w-[180px]">Referencia</Table.Head>
<Table.Head>Procedimiento</Table.Head>
<Table.Head>Movimiento</Table.Head>
<Table.Head class="w-[150px]">Usuario</Table.Head>
<Table.Head class="w-[120px]">Fecha</Table.Head>
<Table.Head class="w-[120px]">Hora</Table.Head>
<Table.Head class="w-[180px]">{text('Referencia', 'Reference')}</Table.Head>
<Table.Head>{text('Procedimiento', 'Procedure')}</Table.Head>
<Table.Head>{text('Movimiento', 'Movement')}</Table.Head>
<Table.Head class="w-[150px]">{text('Usuario', 'User')}</Table.Head>
<Table.Head class="w-[120px]">{text('Fecha', 'Date')}</Table.Head>
<Table.Head class="w-[120px]">{text('Hora', 'Time')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading && logs.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center text-muted-foreground italic">
Cargando...
{text('Cargando...', 'Loading...')}
</Table.Cell>
</Table.Row>
{:else if logs.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center text-muted-foreground">
No se encontraron registros
{text('No se encontraron registros', 'No records found')}
</Table.Cell>
</Table.Row>
{:else}
@@ -266,8 +282,8 @@
<Table.Cell class="text-sm font-bold text-blue-600 dark:text-blue-400">
{log.reference}
</Table.Cell>
<Table.Cell class="text-sm">{log.procedure}</Table.Cell>
<Table.Cell class="text-sm">{log.movement}</Table.Cell>
<Table.Cell class="text-sm">{translateAuditProcedure(log.procedure)}</Table.Cell>
<Table.Cell class="text-sm">{translateAuditMovement(log.movement)}</Table.Cell>
<Table.Cell class="text-sm">{log.username}</Table.Cell>
<Table.Cell class="text-sm">{formatDate(log.date, log.timestamp)}</Table.Cell>
<Table.Cell class="text-sm">{formatTime(log.time, log.timestamp)}</Table.Cell>
@@ -281,7 +297,7 @@
{#if loading && logs.length > 0}
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin text-primary" />
<span class="text-sm text-muted-foreground">Cargando más registros...</span>
<span class="text-sm text-muted-foreground">{text('Cargando más registros...', 'Loading more records...')}</span>
</div>
{/if}
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { getLocale } from '$lib/paraglide/runtime';
import {
coreTasksApi,
type UnifiedTask,
@@ -6,6 +7,7 @@
type UnifiedTaskStatus
} from '$lib/api/dashboard/a76/tasks';
import { companyStore } from '$lib/stores/company.svelte';
import { translateCeleryState, translateTaskGroup, translateTaskName } from '$lib/utils/audit-log-i18n';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -26,6 +28,14 @@
let detailError = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function isSpanish() {
return getLocale() === 'es';
}
function text(es: string, en: string) {
return isSpanish() ? es : en;
}
function hasActive(items: UnifiedTask[]) {
return items.some((x) => x.status === 'pending' || x.status === 'active');
}
@@ -56,7 +66,7 @@
});
if (response.error || !response.data) {
error = response.error || 'Error cargando tareas';
error = response.error || text('Error cargando tareas', 'Error loading tasks');
loading = false;
return;
}
@@ -81,13 +91,13 @@
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'En cola',
active: 'En progreso',
completed: 'Completada',
failed: 'Fallida'
const map: Record<string, { en: string; es: string }> = {
pending: { en: 'Queued', es: 'En cola' },
active: { en: 'In progress', es: 'En progreso' },
completed: { en: 'Completed', es: 'Completada' },
failed: { en: 'Failed', es: 'Fallida' }
};
return map[status] ?? status;
return map[status]?.[isSpanish() ? 'es' : 'en'] ?? status;
}
function statusClass(status: string) {
@@ -123,7 +133,7 @@
if (!detailOpen) return;
detailLoading = false;
if (res.error || !res.data) {
detailError = res.error || 'No se pudo sincronizar el detalle';
detailError = res.error || text('No se pudo sincronizar el detalle', 'Could not sync task details');
return;
}
selectedDetail = res.data;
@@ -151,25 +161,25 @@
<Card.Header class="flex-none py-3">
<div class="flex items-center justify-between">
<div>
<Card.Title>Tareas del Sistema</Card.Title>
<Card.Description class="text-xs">Total: {total} tareas encontradas</Card.Description>
<Card.Title>{text('Tareas del Sistema', 'System Tasks')}</Card.Title>
<Card.Description class="text-xs">{text(`Total: ${total} tareas encontradas`, `Total: ${total} tasks found`)}</Card.Description>
</div>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Buscar por task_id / nombre / error" bind:value={search} class="h-9 w-64" />
<Input placeholder={text('Buscar por task_id / nombre / error', 'Search by task_id / name / error')} bind:value={search} class="h-9 w-64" />
<select
bind:value={statusFilter}
class="flex h-9 w-[160px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
title="Estado"
title={text('Estado', 'Status')}
>
<option value="all">Estado: Todos</option>
<option value="pending">En cola</option>
<option value="active">En progreso</option>
<option value="completed">Completadas</option>
<option value="failed">Fallidas</option>
<option value="all">{text('Estado: Todos', 'Status: All')}</option>
<option value="pending">{statusLabel('pending')}</option>
<option value="active">{statusLabel('active')}</option>
<option value="completed">{statusLabel('completed')}</option>
<option value="failed">{statusLabel('failed')}</option>
</select>
<Button variant="outline" size="sm" class="h-9" onclick={applyFilters}>Aplicar Filtros</Button>
<Button variant="outline" size="sm" class="h-9" onclick={applyFilters}>{text('Aplicar Filtros', 'Apply Filters')}</Button>
<Button size="sm" class="h-9" onclick={() => void loadTasks(true)} disabled={loading}>
{loading ? 'Cargando...' : 'Refrescar'}
{loading ? text('Cargando...', 'Loading...') : text('Refrescar', 'Refresh')}
</Button>
</div>
</div>
@@ -185,22 +195,22 @@
<table class="w-full text-sm">
<thead class="sticky top-0 z-10 bg-background/95 shadow-sm backdrop-blur-sm">
<tr>
<th class="p-2 text-left">Task ID</th>
<th class="p-2 text-left">Tipo</th>
<th class="p-2 text-left">Estado</th>
<th class="p-2 text-left">Progreso</th>
<th class="p-2 text-left">Reintentos</th>
<th class="p-2 text-left">Actualizado</th>
<th class="p-2 text-left">{text('ID de tarea', 'Task ID')}</th>
<th class="p-2 text-left">{text('Tipo', 'Type')}</th>
<th class="p-2 text-left">{text('Estado', 'Status')}</th>
<th class="p-2 text-left">{text('Progreso', 'Progress')}</th>
<th class="p-2 text-left">{text('Reintentos', 'Retries')}</th>
<th class="p-2 text-left">{text('Actualizado', 'Updated')}</th>
</tr>
</thead>
<tbody>
{#if tasks.length === 0 && !loading}
<tr>
<td class="p-4 text-center text-muted-foreground" colspan="6">Sin tareas registradas</td>
<td class="p-4 text-center text-muted-foreground" colspan="6">{text('Sin tareas registradas', 'No tasks found')}</td>
</tr>
{:else if tasks.length === 0 && loading}
<tr>
<td class="p-4 text-center text-muted-foreground italic" colspan="6">Cargando...</td>
<td class="p-4 text-center text-muted-foreground italic" colspan="6">{text('Cargando...', 'Loading...')}</td>
</tr>
{:else}
{#each tasks as task}
@@ -211,11 +221,11 @@
onclick={() => void openDetail(task)}
>
<td class="p-2 font-mono text-xs">{task.task_id}</td>
<td class="p-2">{task.task_group} / {task.task_name}</td>
<td class="p-2">{translateTaskGroup(task.task_group)} / {translateTaskName(task.task_name)}</td>
<td class={`p-2 font-medium ${statusClass(task.status)}`}>
{statusLabel(task.status)}
<span class="ml-1 text-xs font-normal text-muted-foreground"
>({task.celery_state_raw})</span
>({translateCeleryState(task.celery_state_raw)})</span
>
</td>
<td class="p-2">
@@ -241,9 +251,9 @@
onclick={() => {
page -= 1;
void loadTasks(false);
}}>Anterior</Button
}}>{text('Anterior', 'Previous')}</Button
>
<span>Página {page}</span>
<span>{text('Página', 'Page')} {page}</span>
<Button
variant="outline"
size="sm"
@@ -251,7 +261,7 @@
onclick={() => {
page += 1;
void loadTasks(false);
}}>Siguiente</Button
}}>{text('Siguiente', 'Next')}</Button
>
</div>
</div>
@@ -266,21 +276,21 @@
>
<Dialog.Content class="max-h-[70vh] min-h-[320px] w-full max-w-xl overflow-y-auto sm:max-w-xl">
<Dialog.Header>
<Dialog.Title>Detalle de tarea</Dialog.Title>
<Dialog.Title>{text('Detalle de tarea', 'Task Details')}</Dialog.Title>
<Dialog.Description>
{#if detailLoading}
Sincronizando estado con Celery…
{text('Sincronizando estado con Celery…', 'Syncing state with Celery...')}
{:else if selectedDetail}
<span class="font-mono text-xs">{selectedDetail.task_id}</span>
{:else}
Cargando…
{text('Cargando…', 'Loading...')}
{/if}
</Dialog.Description>
</Dialog.Header>
{#if detailLoading}
<div class="flex items-center gap-2 py-4">
<Badge variant="secondary">Sincronizando…</Badge>
<Badge variant="secondary">{text('Sincronizando…', 'Syncing...')}</Badge>
</div>
{/if}
@@ -291,43 +301,43 @@
{#if selectedDetail}
<div class="space-y-2 text-sm">
<div>
<strong>Task:</strong>
<strong>{text('Tarea', 'Task')}:</strong>
<span class="ml-1 font-mono text-xs">{selectedDetail.task_id}</span>
</div>
<div>
<strong>Estado:</strong>
<strong>{text('Estado', 'Status')}:</strong>
<span class={statusClass(selectedDetail.status)}>{statusLabel(selectedDetail.status)}</span>
<span class="text-muted-foreground">({selectedDetail.celery_state_raw})</span>
<span class="text-muted-foreground">({translateCeleryState(selectedDetail.celery_state_raw)})</span>
</div>
<div>
<strong>Progreso:</strong>
<strong>{text('Progreso', 'Progress')}:</strong>
{formatPercent(selectedDetail)}
{#if selectedDetail.progress?.message}
{selectedDetail.progress.message}
{/if}
</div>
<div><strong>Origen:</strong> {selectedDetail.task_origin || '—'}</div>
<div><strong>Solicitante:</strong> {selectedDetail.requested_by_user || '—'}</div>
<div><strong>{text('Origen', 'Origin')}:</strong> {selectedDetail.task_origin || '—'}</div>
<div><strong>{text('Solicitante', 'Requested by')}:</strong> {selectedDetail.requested_by_user || '—'}</div>
<div>
<strong>Error:</strong>
<strong>{text('Error', 'Error')}:</strong>
{selectedDetail.error?.type || '—'}{selectedDetail.error?.message || '—'}
</div>
{#if selectedDetail.started_at}
<div><strong>Inicio:</strong> {new Date(selectedDetail.started_at).toLocaleString()}</div>
<div><strong>{text('Inicio', 'Started')}:</strong> {new Date(selectedDetail.started_at).toLocaleString(isSpanish() ? 'es-MX' : 'en-US')}</div>
{/if}
{#if selectedDetail.finished_at}
<div><strong>Fin:</strong> {new Date(selectedDetail.finished_at).toLocaleString()}</div>
<div><strong>{text('Fin', 'Finished')}:</strong> {new Date(selectedDetail.finished_at).toLocaleString(isSpanish() ? 'es-MX' : 'en-US')}</div>
{/if}
{#if selectedDetail.traceback_excerpt}
<div>
<strong class="block">Traceback (extracto)</strong>
<strong class="block">{text('Traceback (extracto)', 'Traceback (excerpt)')}</strong>
<pre
class="mt-1 max-h-48 overflow-auto rounded border bg-muted/50 p-2 text-xs whitespace-pre-wrap">{selectedDetail.traceback_excerpt}</pre>
</div>
{/if}
{#if selectedDetail.result_summary && Object.keys(selectedDetail.result_summary).length > 0}
<div>
<strong class="block">Resultado (resumen)</strong>
<strong class="block">{text('Resultado (resumen)', 'Result (summary)')}</strong>
<pre
class="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border bg-muted/50 p-2 text-xs">{JSON.stringify(
selectedDetail.result_summary,
@@ -340,7 +350,7 @@
{/if}
<Dialog.Footer class="gap-2 sm:gap-0">
<Button variant="outline" onclick={closeDetail}>Cerrar</Button>
<Button variant="outline" onclick={closeDetail}>{text('Cerrar', 'Close')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>