Merge branch 'fix/transportes-traduccion-erronea' into feature/visible-clicks-csv

This commit is contained in:
2026-04-29 08:39:09 -06:00
7 changed files with 414 additions and 118 deletions

View File

@@ -5,6 +5,7 @@ import type { Vehicle } from '$lib/api/dashboard/a76/vehicles';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
import { tv, tvTransportType } from '$lib/i18n/vehicles';
export function createColumns(
onSuccess: () => void,
@@ -13,49 +14,50 @@ export function createColumns(
return [
{
accessorKey: 'vehicle_key',
header: 'Clave',
header: tv('col_key'),
cell: ({ row }) => {
return row.original.vehicle_key;
}
},
{
accessorKey: 'brand',
header: 'Marca',
header: tv('col_brand'),
cell: ({ row }) => {
return row.original.brand || '-';
}
},
{
accessorKey: 'year',
header: 'Año',
header: tv('col_year'),
cell: ({ row }) => {
return row.original.year || '-';
}
},
{
accessorKey: 'plate_number',
header: 'Placas',
header: tv('col_plate'),
cell: ({ row }) => {
return row.original.plate_number || '-';
}
},
{
accessorKey: 'transporter_key',
header: 'Transportista',
header: tv('col_transporter'),
cell: ({ row }) => {
return row.original.transporter_key || '-';
}
},
{
accessorKey: 'transport_type',
header: 'Tipo Transporte',
header: tv('col_transport_type'),
cell: ({ row }) => {
return row.original.transport_type || '-';
if (!row.original.transport_type) return '-';
return `${row.original.transport_type} - ${tvTransportType(row.original.transport_type, row.original.transport_type)}`;
}
},
{
id: 'actions',
header: 'Acciones',
header: tv('col_actions'),
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,

View File

@@ -11,6 +11,7 @@
import { countriesApi, type Country } from '$lib/api/dashboard/reference_data/countries';
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
import { companyStore } from '$lib/stores/company.svelte';
import { tv, tvTransportType } from '$lib/i18n/vehicles';
import { browser } from '$app/environment';
let {
@@ -25,7 +26,7 @@
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo');
const title = $derived(isEdit ? tv('dialog_edit_title') : tv('dialog_new_title'));
// Estado del formulario
let formData = $state<Vehicle>({
@@ -66,35 +67,16 @@
let refsLoading = $state(false);
const ENTITY_OPTS = [
{ value: '', label: '— Sin código —' },
{ value: '', label: tv('no_code') },
{ value: 'C', label: 'C' },
{ value: 'I', label: 'I' },
{ value: 'A', label: 'A' },
{ value: 'B', label: 'B' }
];
const TRANSPORT_TYPE_TRANSLATIONS: Record<string, string> = {
AR: 'Camión Blindado',
AU: 'Automóviles',
BT: 'Camión de Caja',
BU: 'Autobús',
BV: 'Camión de Bebidas',
BY: 'Bicicleta',
CO: 'Vehículo de Construcción',
EV: 'Vehículo de Emergencia',
FE: 'Ferry',
FM: 'Tractor Agrícola',
GB: 'Camión de Basura',
MC: 'Motocicleta',
OC: 'Otro',
PM: 'Camioneta con cabina',
PN: 'Camión Panel',
PU: 'Camioneta (Pick-up)',
PV: 'Pasajero',
RV: 'Vehículo Recreativo (RV)',
TR: 'Tractocamión',
TV: 'Van'
};
function getTransportTypeText(transportCode?: string, description?: string): string {
return tvTransportType(transportCode, description);
}
const countryM3 = $derived(
countries.find((c) => c.ame_key === formData.country)?.m3_key ?? null
@@ -110,7 +92,7 @@
try {
const [tr, tt, cc, ss] = await Promise.all([
transportersApi.list(cid, { page: 1, page_size: 100 }),
transportTypesApi.list(1, 100),
transportTypesApi.list(cid, 1, 100),
countriesApi.list(1, 100),
statesApi.list(1, 100)
]);
@@ -181,12 +163,12 @@
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
throw new Error(tv('no_company_selected'));
}
// Validación básica
if (!formData.vehicle_key.trim()) {
throw new Error('La clave del vehículo es requerida');
throw new Error(tv('vehicle_key_required'));
}
let response;
@@ -210,7 +192,7 @@
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el vehículo';
error = e instanceof Error ? e.message : tv('save_error');
} finally {
loading = false;
}
@@ -238,8 +220,8 @@
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del vehículo'
: 'Completa los datos para crear un nuevo vehículo de transporte'}
? tv('dialog_edit_description')
: tv('dialog_new_description')}
</Dialog.Description>
</Dialog.Header>
@@ -258,21 +240,21 @@
<Tabs.Root value="general" class="w-full">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="general">Información General</Tabs.Trigger>
<Tabs.Trigger value="details">Seguro y Detalles</Tabs.Trigger>
<Tabs.Trigger value="general">{tv('tab_general')}</Tabs.Trigger>
<Tabs.Trigger value="details">{tv('tab_details')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="mt-6 space-y-8">
<!-- Información del Vehículo -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-bold uppercase tracking-wider text-muted-foreground">
Identificación del Vehículo
{tv('section_vehicle_identification')}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="vehicle_key"
>Clave del Vehículo <span class="text-destructive">*</span></Label
>{tv('label_vehicle_key')} <span class="text-destructive">*</span></Label
>
<Input
id="vehicle_key"
@@ -280,27 +262,27 @@
disabled={isEdit}
required
maxlength={14}
placeholder="Ej: VH001"
placeholder={tv('placeholder_vehicle_key')}
/>
</div>
<div class="grid gap-2">
<Label for="brand">Marca</Label>
<Input id="brand" bind:value={formData.brand} maxlength={20} placeholder="Ej: Kenworth" />
<Label for="brand">{tv('label_brand')}</Label>
<Input id="brand" bind:value={formData.brand} maxlength={20} placeholder={tv('placeholder_brand')} />
</div>
<div class="grid gap-2">
<Label for="year">Año</Label>
<Input id="year" bind:value={formData.year} maxlength={4} placeholder="YYYY" />
<Label for="year">{tv('label_year')}</Label>
<Input id="year" bind:value={formData.year} maxlength={4} placeholder={tv('placeholder_year')} />
</div>
<div class="grid gap-2">
<Label for="plate_number">Placas</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} placeholder="Placas actuales" />
<Label for="plate_number">{tv('label_plate_number')}</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} placeholder={tv('placeholder_plate_number')} />
</div>
<div class="grid gap-2">
<Label for="series">Serie / VIN</Label>
<Input id="series" bind:value={formData.series} maxlength={30} placeholder="Número de serie" />
<Label for="series">{tv('label_series')}</Label>
<Input id="series" bind:value={formData.series} maxlength={30} placeholder={tv('placeholder_series')} />
</div>
</div>
</section>
@@ -308,22 +290,22 @@
<!-- Datos de Transporte -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-bold uppercase tracking-wider text-muted-foreground">
Datos de Transporte
{tv('section_transport_data')}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="transporter_key">Transportista</Label>
<Label for="transporter_key">{tv('label_transporter')}</Label>
<Select.Root type="single" bind:value={formData.transporter_key} disabled={refsLoading}>
<Select.Trigger class="w-full">
{refsLoading
? '...'
: formData.transporter_key
? `${formData.transporter_key} ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name ?? ''}`
: '— Seleccionar transportista —'}
: tv('select_transporter')}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="" label={tv('empty')}>{tv('empty')}</Select.Item>
{#each transporters as t}
<Select.Item value={t.transporter_key} label={t.transporter_key}>
{t.transporter_key} — {t.name || t.short_name || ''}
@@ -334,30 +316,30 @@
</div>
<div class="grid gap-2">
<Label for="transport_identifier">ID Transporte</Label>
<Label for="transport_identifier">{tv('label_transport_identifier')}</Label>
<Input
id="transport_identifier"
bind:value={formData.transport_identifier}
maxlength={30}
placeholder="Identificador único"
placeholder={tv('placeholder_transport_identifier')}
/>
</div>
<div class="grid gap-2">
<Label for="transport_type">Tipo de Transporte</Label>
<Label for="transport_type">{tv('label_transport_type')}</Label>
<Select.Root type="single" bind:value={formData.transport_type} disabled={refsLoading}>
<Select.Trigger class="w-full" id="transport_type">
{refsLoading
? '...'
: formData.transport_type
? `${formData.transport_type} ${TRANSPORT_TYPE_TRANSLATIONS[formData.transport_type] || transportTypes.find((x) => x.transport_code === formData.transport_type)?.description || ''}`
: '— Opcional'}
? `${formData.transport_type} ${getTransportTypeText(formData.transport_type, transportTypes.find((x) => (x.transport_code || '').trim().toUpperCase() === (formData.transport_type || '').trim().toUpperCase())?.description)}`
: tv('optional')}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="" label={tv('empty')}>{tv('empty')}</Select.Item>
{#each transportTypes as x}
<Select.Item value={x.transport_code} label={x.transport_code}>
{x.transport_code} — {TRANSPORT_TYPE_TRANSLATIONS[x.transport_code] || x.description}
{x.transport_code} — {getTransportTypeText(x.transport_code, x.description)}
</Select.Item>
{/each}
</Select.Content>
@@ -365,11 +347,11 @@
</div>
<div class="grid gap-2">
<Label for="entity_code">Código de Entidad</Label>
<Label for="entity_code">{tv('label_entity_code')}</Label>
<Select.Root type="single" bind:value={formData.entity_code}>
<Select.Trigger class="w-full" id="entity_code">
{ENTITY_OPTS.find((o) => o.value === (formData.entity_code || ''))?.label ??
'— Sin código —'}
tv('no_code')}
</Select.Trigger>
<Select.Content>
{#each ENTITY_OPTS as o}
@@ -377,12 +359,12 @@
{/each}
</Select.Content>
</Select.Root>
<p class="text-[10px] text-muted-foreground uppercase">Obligatorio al crear (reglas CSV)</p>
<p class="text-[10px] text-muted-foreground uppercase">{tv('entity_required_hint')}</p>
</div>
<div class="grid gap-2">
<Label for="sct_permission">Permiso SCT</Label>
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} placeholder="Número de permiso" />
<Label for="sct_permission">{tv('label_sct_permission')}</Label>
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} placeholder={tv('placeholder_sct_permission')} />
</div>
</div>
</section>
@@ -392,31 +374,31 @@
<!-- Seguro y Otros -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-bold uppercase tracking-wider text-muted-foreground">
Seguro y Otros
{tv('section_insurance')}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="insurance_company_name">Aseguradora</Label>
<Label for="insurance_company_name">{tv('label_insurance_company')}</Label>
<Input
id="insurance_company_name"
bind:value={formData.insurance_company_name}
maxlength={30}
placeholder="Nombre de la compañía"
placeholder={tv('placeholder_insurance_company')}
/>
</div>
<div class="grid gap-2">
<Label for="insurance_number">Póliza</Label>
<Label for="insurance_number">{tv('label_insurance_number')}</Label>
<Input
id="insurance_number"
bind:value={formData.insurance_number}
maxlength={20}
placeholder="Número de póliza"
placeholder={tv('placeholder_insurance_number')}
/>
</div>
<div class="grid gap-2">
<Label for="insurance_amount">Monto</Label>
<Label for="insurance_amount">{tv('label_insurance_amount')}</Label>
<Input
id="insurance_amount"
type="number"
@@ -426,7 +408,7 @@
</div>
<div class="grid gap-2">
<Label for="dot_number">Número DOT</Label>
<Label for="dot_number">{tv('label_dot_number')}</Label>
<Input id="dot_number" bind:value={formData.dot_number} maxlength={8} placeholder="DOT ID" />
</div>
</div>
@@ -435,22 +417,22 @@
<!-- Ubicación y Detalles -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-bold uppercase tracking-wider text-muted-foreground">
Ubicación y Detalles
{tv('section_location')}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="country">País (AME)</Label>
<Label for="country">{tv('label_country')}</Label>
<Select.Root type="single" bind:value={formData.country} disabled={refsLoading}>
<Select.Trigger class="w-full" id="country">
{refsLoading
? '...'
: formData.country
? `${formData.country} ${countries.find((c) => c.ame_key === formData.country)?.description_es ?? ''}`
: '— Opcional'}
: tv('optional')}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="" label={tv('empty')}>{tv('empty')}</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>
{c.ame_key}{c.description_es}
@@ -460,16 +442,16 @@
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Label for="state">{tv('label_state')}</Label>
<Select.Root type="single" bind:value={formData.state} disabled={refsLoading}>
<Select.Trigger class="w-full" id="state">
{refsLoading
? '...'
: formData.state ||
(formData.country ? '— Selecciona —' : '— Primero el país —')}
(formData.country ? tv('select_state') : tv('select_country_first'))}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="" label={tv('empty')}>{tv('empty')}</Select.Item>
{#each statesFiltered as s}
<Select.Item value={s.description} label={s.description}>
{s.description}
@@ -480,21 +462,21 @@
</div>
<div class="grid gap-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} maxlength={30} placeholder="Ciudad/Localidad" />
<Label for="city">{tv('label_city')}</Label>
<Input id="city" bind:value={formData.city} maxlength={30} placeholder={tv('placeholder_city')} />
</div>
<div class="grid gap-2">
<Label for="color">Color</Label>
<Input id="color" bind:value={formData.color} maxlength={20} placeholder="Color del vehículo" />
<Label for="color">{tv('label_color')}</Label>
<Input id="color" bind:value={formData.color} maxlength={20} placeholder={tv('placeholder_color')} />
</div>
<div class="grid gap-2">
<Label for="container_key">Tipo Contenedor</Label>
<Label for="container_key">{tv('label_container_type')}</Label>
<Input
id="container_key"
bind:value={formData.container_key}
maxlength={3}
placeholder="Ej: 40G"
placeholder={tv('placeholder_container_type')}
/>
</div>
</div>
@@ -502,12 +484,12 @@
<section class="space-y-4 pt-4">
<div class="grid gap-2">
<Label for="description">Descripción Adicional</Label>
<Label for="description">{tv('label_additional_description')}</Label>
<Input
id="description"
bind:value={formData.description}
maxlength={100}
placeholder="Notas adicionales sobre el vehículo..."
placeholder={tv('placeholder_additional_description')}
/>
</div>
</section>
@@ -516,10 +498,10 @@
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
{tv('cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
{loading ? tv('saving') : isEdit ? tv('update') : tv('create')}
</Button>
</Dialog.Footer>
</form>

View File

@@ -5,6 +5,7 @@
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
import { tv } from '$lib/i18n/vehicles';
let {
item,
@@ -26,14 +27,14 @@
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
tv('delete_confirm', { key: item.vehicle_key })
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
alert(tv('no_company_error'));
return;
}
@@ -45,25 +46,25 @@
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
error = tv('session_expired');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
alert(`${tv('delete_error_title')}\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`);
alert(tv('delete_success_item', { key: item.vehicle_key }));
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
error = e instanceof Error ? e.message : tv('delete_generic_error');
alert(tv('delete_error_alert', { error }));
console.error('Error deleting:', e);
} finally {
loading = false;
@@ -88,18 +89,18 @@
<DropdownMenu.Trigger disabled={!canEdit && !canDelete}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<span class="sr-only">{tv('menu_open')}</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Label>{tv('menu_actions')}</DropdownMenu.Label>
<DropdownMenu.Separator />
{#if canEdit}
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
{tv('edit')}
</DropdownMenu.Item>
{/if}
{#if canDelete}
@@ -110,7 +111,7 @@
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
{tv('delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>