fix/conductores-longitud-placa

This commit is contained in:
2026-04-17 12:36:05 -06:00
parent 20bcb7797e
commit 1036a2edb5
6 changed files with 500 additions and 284 deletions

View File

@@ -0,0 +1,39 @@
"""fix driver transporter_key length and validations
Revision ID: f7a8b9c0d1e2
Revises: e76_app_settings
Create Date: 2026-04-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f7a8b9c0d1e2"
down_revision = "e76_app_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"driver",
"transporter_key",
schema="a76",
existing_type=sa.String(length=5),
type_=sa.String(length=30),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"driver",
"transporter_key",
schema="a76",
existing_type=sa.String(length=30),
type_=sa.String(length=5),
existing_nullable=False,
)

View File

@@ -1,10 +1,13 @@
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
TRANSPORTER_KEY_MAX_LENGTH = 30
class DriverBaseDTO(BaseModel):
transporter_key: str
transporter_key: str = Field(..., max_length=TRANSPORTER_KEY_MAX_LENGTH)
driver_id: Optional[int] = None
line: int
driver_name: Optional[str] = None

View File

@@ -10,7 +10,7 @@ class Driver(Base, TenantScopedMixin, TimestampMixin):
)
transporter_key = Column(
String(5),
String(30),
ForeignKey("a76.transporter.transporter_key", ondelete="CASCADE"),
primary_key=True,
nullable=False,

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
@@ -28,34 +29,132 @@
let countries = $state<Country[]>([]);
let countriesLoading = $state(false);
let formData = $state<Driver & { lineStr?: string }>({
transporter_key: '',
line: 0,
driver_name: '',
license_number: '',
first_name: '',
last_name: '',
badge_number: '',
express_line_id: '',
ace_id: '',
birth_country: '',
hazardous_material_auth: '',
hazardous_material_state: '',
class_type: ''
});
// Convierte null/undefined a '' para evitar binding roto en inputs
function s(v: string | null | undefined): string {
return v ?? '';
}
// birth_date se guarda como entero YYYYMMDD; el formulario usa string YYYY-MM-DD para <input type="date">
function birthDateToInput(v: number | null | undefined): string {
if (!v) return '';
const s = String(v).padStart(8, '0');
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
function inputToBirthDate(v: string): number | undefined {
if (!v) return undefined;
const d = v.replace(/-/g, '');
return d.length === 8 ? parseInt(d, 10) : undefined;
}
function emptyForm(): Driver & { lineStr: string; birthDateStr: string } {
return {
transporter_key: '', line: 0, lineStr: '', birthDateStr: '',
driver_name: '', license_number: '', first_name: '', last_name: '',
badge_number: '', unique_badge_number: '', express_line_id: '', ace_id: '',
gender: '', birth_country: '', birth_date: undefined,
hazardous_material_auth: '', hazardous_material_state: '',
class_type: '',
id_key1: '', id_number1: '', id_state1: '', id_country1: '',
id_key2: '', id_number2: '', id_state2: '', id_country2: ''
};
}
function fromItem(i: Driver): Driver & { lineStr: string; birthDateStr: string } {
return {
...i,
lineStr: String(i.line),
birthDateStr: birthDateToInput(i.birth_date),
driver_name: s(i.driver_name),
license_number: s(i.license_number),
first_name: s(i.first_name),
last_name: s(i.last_name),
badge_number: s(i.badge_number),
unique_badge_number: s(i.unique_badge_number),
express_line_id: s(i.express_line_id),
ace_id: s(i.ace_id),
gender: s(i.gender),
birth_country: s(i.birth_country),
hazardous_material_auth: s(i.hazardous_material_auth),
hazardous_material_state: s(i.hazardous_material_state),
class_type: s(i.class_type),
id_key1: s(i.id_key1), id_number1: s(i.id_number1),
id_state1: s(i.id_state1), id_country1: s(i.id_country1),
id_key2: s(i.id_key2), id_number2: s(i.id_number2),
id_state2: s(i.id_state2), id_country2: s(i.id_country2)
};
}
let formData = $state<Driver & { lineStr: string; birthDateStr: string }>(emptyForm());
let loading = $state(false);
let error = $state<string | null>(null);
// Cargar transportistas al abrir el diálogo en modo creación (backend max page_size=100)
// Mapeo de nombres de campo CSV → etiqueta legible para el usuario
const FIELD_LABEL: Record<string, string> = {
'TRANSPORTISTA': 'Transportista',
'CLAVE CONDUCTOR': 'Clave Conductor',
'LINEA': 'Línea',
'LICENCIA': 'Número de Licencia',
'PERMISO LINEA EXPRESS': 'Express Line ID',
'IDENTIFICACION ACE': 'ACE ID',
'PAIS NACIMIENTO': 'País de Nacimiento',
'TRANSPORTA MAT. PELIGROSO?': 'Mat. Peligroso',
'PERMISO MAT. PELIGROSO': 'Estado de Autorización',
'NOMBRE(S)': 'Nombre(s)',
'APELLIDO PATERNO': 'Apellido Paterno',
'SEXO': 'Género',
'FECHA NACIMIENTO': 'Fecha de Nacimiento',
'FORMA IDENTIFICACION 1': 'Tipo ID 1',
'NUM. IDENTIFICACION 1': 'Núm. ID 1',
'ESTADO': 'Estado ID 1',
'PAIS': 'País ID 1',
'FORMA IDENTIFICACION 2': 'Tipo ID 2',
'NUM. IDENTIFICACION 2': 'Núm. ID 2',
'ESTADO 2': 'Estado ID 2',
'PAIS 2': 'País ID 2'
};
// Claves válidas de forma de identificación (paridad Clarion)
const FORMA_ID_OPCIONES = [
{ value: 'ACW', label: 'ACW — Pasaporte' },
{ value: 'ALR', label: 'ALR — Residencia' },
{ value: 'BCP', label: 'BCP — Permiso Cruce' },
{ value: 'BCN', label: 'BCN — Acta Nacimiento' },
{ value: 'CDN', label: 'CDN — Ciudadanía' },
{ value: 'CON', label: 'CON — Cert. Naturalización' },
{ value: 'OTD', label: 'OTD — Otro' },
{ value: 'REP', label: 'REP — Pasaporte' },
{ value: 'RTP', label: 'RTP — Tarjeta de Paso' },
{ value: '5J', label: '5J' },
{ value: '5K', label: '5K' },
{ value: '30', label: '30' }
];
// Clase de licencia (String(1), Clarion muestra A,B,C,D,E)
const CLASE_OPCIONES = ['A', 'B', 'C', 'D', 'E'];
function humanizeValidationErrors(errors: Array<{ col?: string; msg?: string }>): string {
return errors
.map((e) => {
const label = (e.col && FIELD_LABEL[e.col]) ? FIELD_LABEL[e.col] : (e.col ?? 'Campo');
const msg = e.msg ?? 'error';
const humanMsg = msg === 'Requerido'
? 'es obligatorio'
: msg.startsWith('Maximo')
? msg.replace('Maximo', 'máximo').replace('caracteres', 'caracteres')
: msg;
return `${label}: ${humanMsg}`;
})
.join(' · ');
}
// Cargar transportistas al abrir el diálogo en modo creación
$effect(() => {
if (open && !item && companyStore.activeCompany) {
transportersLoading = true;
transportersApi
.list(companyStore.activeCompany.id, { page: 1, page_size: 100 })
.then((res) => {
if (res.data?.items) transporters = res.data.items;
else transporters = [];
transporters = res.data?.items ?? [];
})
.catch(() => (transporters = []))
.finally(() => (transportersLoading = false));
@@ -65,8 +164,7 @@
countriesApi
.list(1, 100)
.then((res) => {
if (res.data?.items) countries = res.data.items;
else countries = [];
countries = res.data?.items ?? [];
})
.catch(() => (countries = []))
.finally(() => (countriesLoading = false));
@@ -79,109 +177,95 @@
loading = false;
return;
}
if (item) {
formData = {
...item,
lineStr: String(item.line)
};
} else {
formData = {
transporter_key: '',
line: 0,
lineStr: '',
driver_name: '',
license_number: '',
first_name: '',
last_name: '',
badge_number: '',
express_line_id: '',
ace_id: '',
birth_country: '',
hazardous_material_auth: '',
hazardous_material_state: '',
class_type: ''
};
}
formData = item ? fromItem(item) : emptyForm();
});
async function handleSubmit() {
if (loading) return;
error = null;
loading = true;
// Validación client-side
if (!formData.transporter_key?.trim()) {
error = 'Selecciona un transportista de la lista';
return;
}
if (!formData.driver_name?.trim()) {
error = 'Nombre del Conductor es obligatorio';
return;
}
const lineNum = isEdit ? item!.line : parseInt(formData.lineStr, 10);
if (!isEdit && (Number.isNaN(lineNum) || lineNum < 1)) {
error = 'La línea debe ser un número entero mayor a 0';
return;
}
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) {
throw new Error('No hay una compañía seleccionada');
}
if (!company) throw new Error('No hay una compañía seleccionada');
if (!formData.transporter_key?.trim()) {
throw new Error('Selecciona un transportista de la lista');
}
const lineNum = isEdit ? item!.line : parseInt(String(formData.lineStr ?? formData.line), 10);
if (!isEdit && (Number.isNaN(lineNum) || lineNum < 1)) {
throw new Error('La línea debe ser un número mayor a 0');
}
if (isEdit && item) {
const response = await driversApi.update(
item.transporter_key,
item.line,
{
const allFields = {
driver_name: formData.driver_name || undefined,
license_number: formData.license_number || undefined,
first_name: formData.first_name || undefined,
last_name: formData.last_name || undefined,
badge_number: formData.badge_number || undefined,
unique_badge_number: formData.unique_badge_number || undefined,
express_line_id: formData.express_line_id || undefined,
ace_id: formData.ace_id || undefined,
gender: formData.gender || undefined,
birth_date: inputToBirthDate(formData.birthDateStr),
birth_country: formData.birth_country || undefined,
hazardous_material_auth: formData.hazardous_material_auth || undefined,
hazardous_material_state: formData.hazardous_material_state || undefined,
class_type: formData.class_type || undefined
class_type: formData.class_type || undefined,
id_key1: formData.id_key1 || undefined,
id_number1: formData.id_number1 || undefined,
id_state1: formData.id_state1 || undefined,
id_country1: formData.id_country1 || undefined,
id_key2: formData.id_key2 || undefined,
id_number2: formData.id_number2 || undefined,
id_state2: formData.id_state2 || undefined,
id_country2: formData.id_country2 || undefined
};
if (isEdit && item) {
const response = await driversApi.update(
item.transporter_key,
item.line,
allFields,
company.id
);
if (response.error) {
const ve = response.validationErrors as Array<{ col?: string; msg?: string }> | undefined;
throw new Error(ve?.length ? humanizeValidationErrors(ve) : response.error);
}
} else {
const response = await driversApi.create(
{
transporter_key: formData.transporter_key.trim(),
line: lineNum,
...allFields,
company_id: company.id,
tenant_id: company.tenant_id
},
company.id
);
if (response.error) {
const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors;
if (ve?.length) throw new Error(ve.map((e) => e.msg).join(' · '));
throw new Error(response.error);
}
} else {
const payload = {
transporter_key: String(formData.transporter_key).trim(),
line: lineNum,
driver_name: formData.driver_name || undefined,
license_number: formData.license_number || undefined,
first_name: formData.first_name || undefined,
last_name: formData.last_name || undefined,
badge_number: formData.badge_number || undefined,
express_line_id: formData.express_line_id || undefined,
ace_id: formData.ace_id || undefined,
birth_country: formData.birth_country || undefined,
hazardous_material_auth: formData.hazardous_material_auth || undefined,
hazardous_material_state: formData.hazardous_material_state || undefined,
class_type: formData.class_type || undefined,
company_id: company.id,
tenant_id: company.tenant_id
};
const response = await driversApi.create(payload, company.id);
if (response.error) {
const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors;
if (ve?.length) throw new Error(ve.map((e) => e.msg).join(' · '));
throw new Error(response.error);
if (response.status === 409) {
throw new Error(
`Ya existe un conductor con línea ${lineNum} para el transportista "${formData.transporter_key}". Usa un número de línea diferente.`
);
}
const ve = response.validationErrors as Array<{ col?: string; msg?: string }> | undefined;
throw new Error(ve?.length ? humanizeValidationErrors(ve) : response.error);
}
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
if (e && typeof e === 'object' && 'message' in e) {
error = (e as { message: string }).message;
} else {
error = 'Error al guardar el conductor';
}
error = e instanceof Error ? e.message : 'Error al guardar el conductor';
} finally {
loading = false;
}
@@ -204,165 +288,275 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del conductor'
: 'Completa los datos para crear un nuevo conductor'}
{isEdit ? 'Modifica los datos del conductor' : 'Completa los datos para crear un nuevo conductor'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">{error}</div>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="transporter_key"
>Transportista <span class="text-destructive">*</span></Label
>
{#if isEdit}
<Input
id="transporter_key"
value={formData.transporter_key}
disabled
class="bg-muted"
/>
{:else}
<Select.Root
type="single"
bind:value={formData.transporter_key}
disabled={transportersLoading}
>
<Select.Trigger class="w-full">
{transportersLoading
? 'Cargando transportistas...'
: transporters.length === 0
? 'No hay transportistas'
: transporters.find((t) => t.transporter_key === formData.transporter_key)
? `${formData.transporter_key} - ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}`
: 'Seleccionar transportista'}
</Select.Trigger>
<Select.Content>
{#each transporters as t}
<Select.Item value={t.transporter_key} label={t.transporter_key}>
{t.transporter_key} — {t.name || t.short_name || 'Sin nombre'}
</Select.Item>
{/each}
{#if !transportersLoading && transporters.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">
No hay transportistas. Crea uno en el catálogo Transportistas.
</div>
{/if}
</Select.Content>
</Select.Root>
{/if}
</div>
<Tabs.Root value="generales">
<Tabs.List class="w-full">
<Tabs.Trigger value="generales" class="flex-1">1) Generales</Tabs.Trigger>
<Tabs.Trigger value="identificaciones" class="flex-1">2) Identificaciones</Tabs.Trigger>
</Tabs.List>
<div class="grid gap-2">
<Label for="line">Línea <span class="text-destructive">*</span></Label>
<Input
id="line"
type="text"
inputmode="numeric"
pattern="[0-9]*"
bind:value={formData.lineStr}
disabled={isEdit}
required
placeholder="Ej: 1"
/>
</div>
<!-- Tab 1: Generales -->
<Tabs.Content value="generales" class="mt-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="driver_name">Nombre del Conductor</Label>
<Input id="driver_name" bind:value={formData.driver_name} maxlength={80} />
</div>
<!-- Transportista -->
<div class="grid gap-2">
<Label for="transporter_key">Transportista <span class="text-destructive">*</span></Label>
{#if isEdit}
<Input id="transporter_key" value={formData.transporter_key} disabled class="bg-muted" />
{:else}
<Select.Root type="single" bind:value={formData.transporter_key} disabled={transportersLoading}>
<Select.Trigger class="w-full">
{transportersLoading
? 'Cargando...'
: transporters.find((t) => t.transporter_key === formData.transporter_key)
? `${formData.transporter_key} ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}`
: 'Seleccionar transportista'}
</Select.Trigger>
<Select.Content class="max-h-60">
{#each transporters as t}
<Select.Item value={t.transporter_key} label={t.transporter_key}>
{t.transporter_key} — {t.name || t.short_name || 'Sin nombre'}
</Select.Item>
{/each}
{#if !transportersLoading && transporters.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">No hay transportistas. Crea uno primero.</div>
{/if}
</Select.Content>
</Select.Root>
{/if}
</div>
<div class="grid gap-2">
<Label for="first_name">Nombre</Label>
<Input id="first_name" bind:value={formData.first_name} maxlength={20} />
</div>
<!-- Línea -->
<div class="grid gap-2">
<Label for="line">Línea <span class="text-destructive">*</span></Label>
<Input id="line" type="text" inputmode="numeric" pattern="[0-9]*" bind:value={formData.lineStr} disabled={isEdit} placeholder="Ej: 1" />
</div>
<div class="grid gap-2">
<Label for="last_name">Apellido</Label>
<Input id="last_name" bind:value={formData.last_name} maxlength={20} />
</div>
<!-- Clave Conductor (driver_name) - full width -->
<div class="grid gap-2 md:col-span-2">
<Label for="driver_name">* Clave Conductor <span class="text-destructive">*</span></Label>
<Input id="driver_name" bind:value={formData.driver_name} maxlength={80} />
</div>
<div class="grid gap-2">
<Label for="license_number">Número de Licencia</Label>
<Input id="license_number" bind:value={formData.license_number} maxlength={29} />
</div>
<!-- Número de Licencia + Clase -->
<div class="grid gap-2">
<Label for="license_number">Número de Licencia</Label>
<Input id="license_number" bind:value={formData.license_number} maxlength={29} />
</div>
<div class="grid gap-2">
<Label for="class_type">Clase</Label>
<Select.Root type="single" bind:value={formData.class_type}>
<Select.Trigger class="w-full" id="class_type">
{formData.class_type || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each CLASE_OPCIONES as c}
<Select.Item value={c} label={c}>{c}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="badge_number">Número de Placa/Insignia</Label>
<Input id="badge_number" bind:value={formData.badge_number} maxlength={20} />
</div>
<!-- Núm. Gafete -->
<div class="grid gap-2">
<Label for="badge_number">Núm. Gafete</Label>
<Input id="badge_number" bind:value={formData.badge_number} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="express_line_id">Express Line ID</Label>
<Input id="express_line_id" bind:value={formData.express_line_id} maxlength={17} />
</div>
<!-- Núm. Gafete Único -->
<div class="grid gap-2">
<Label for="unique_badge_number">Núm. Gafete Único</Label>
<Input id="unique_badge_number" bind:value={formData.unique_badge_number} maxlength={100} />
</div>
<div class="grid gap-2">
<Label for="ace_id">ACE ID</Label>
<Input id="ace_id" bind:value={formData.ace_id} maxlength={20} />
</div>
<!-- Nombre(s) -->
<div class="grid gap-2">
<Label for="first_name">Nombre(s)</Label>
<Input id="first_name" bind:value={formData.first_name} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="birth_country">País de Nacimiento (clave americana)</Label>
<Select.Root type="single" bind:value={formData.birth_country} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="birth_country">
{countriesLoading
? 'Cargando países...'
: formData.birth_country
? `${formData.birth_country} ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}`
: '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>
{c.ame_key}{c.description_es}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Apellido Paterno -->
<div class="grid gap-2">
<Label for="last_name">Apellido Paterno</Label>
<Input id="last_name" bind:value={formData.last_name} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="hazardous_material_auth">Auth. Material Peligroso</Label>
<Input id="hazardous_material_auth" bind:value={formData.hazardous_material_auth} maxlength={2} />
</div>
<!-- Fecha Nacimiento + Género -->
<div class="grid gap-2">
<Label for="birthDateStr">Fecha de Nacimiento</Label>
<Input id="birthDateStr" type="date" bind:value={formData.birthDateStr} />
</div>
<div class="grid gap-2">
<Label for="gender">Género (F o M)</Label>
<Select.Root type="single" bind:value={formData.gender}>
<Select.Trigger class="w-full" id="gender">
{formData.gender || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="M" label="M">M — Masculino</Select.Item>
<Select.Item value="F" label="F">F — Femenino</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="hazardous_material_state">Estado Material Peligroso</Label>
<Input id="hazardous_material_state" bind:value={formData.hazardous_material_state} maxlength={30} />
</div>
<!-- País Nacimiento -->
<div class="grid gap-2">
<Label for="birth_country">País Nacimiento</Label>
<Select.Root type="single" bind:value={formData.birth_country} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="birth_country">
{countriesLoading ? 'Cargando...' : formData.birth_country ? `${formData.birth_country} ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key} — {c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="class_type">Tipo de Clase</Label>
<Input id="class_type" bind:value={formData.class_type} maxlength={1} />
</div>
</div>
<!-- Mat. Peligroso + Estado -->
<div class="grid gap-2">
<Label for="hazardous_material_auth">¿Autorizado Mat. Peligroso?</Label>
<Select.Root type="single" bind:value={formData.hazardous_material_auth}>
<Select.Trigger class="w-full" id="hazardous_material_auth">
{formData.hazardous_material_auth || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="SI" label="SI">SI</Select.Item>
<Select.Item value="NO" label="NO">NO</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="hazardous_material_state">Estado de Autorización</Label>
<Input id="hazardous_material_state" bind:value={formData.hazardous_material_state} maxlength={30} />
</div>
</div>
</Tabs.Content>
<!-- Tab 2: Identificaciones -->
<Tabs.Content value="identificaciones" class="mt-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Express Line ID + ACE ID -->
<div class="grid gap-2">
<Label for="express_line_id">Permiso Línea Express</Label>
<Input id="express_line_id" bind:value={formData.express_line_id} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="ace_id">Identificación ACE</Label>
<Input id="ace_id" bind:value={formData.ace_id} maxlength={20} />
</div>
<!-- Separador Identificación 1 -->
<div class="md:col-span-2 border-t pt-2">
<p class="text-sm font-medium text-muted-foreground">Primera Identificación</p>
</div>
<div class="grid gap-2">
<Label for="id_key1">Forma de Identificación 1</Label>
<Select.Root type="single" bind:value={formData.id_key1}>
<Select.Trigger class="w-full" id="id_key1">
{FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key1)?.label || '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each FORMA_ID_OPCIONES as o}
<Select.Item value={o.value} label={o.value}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="id_number1">Núm. Identificación 1</Label>
<Input id="id_number1" bind:value={formData.id_number1} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="id_state1">Estado ID 1</Label>
<Input id="id_state1" bind:value={formData.id_state1} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="id_country1">País ID 1</Label>
<Select.Root type="single" bind:value={formData.id_country1} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="id_country1">
{formData.id_country1 ? `${formData.id_country1} ${countries.find((c) => c.ame_key === formData.id_country1)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key} — {c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Separador Identificación 2 -->
<div class="md:col-span-2 border-t pt-2">
<p class="text-sm font-medium text-muted-foreground">Segunda Identificación</p>
</div>
<div class="grid gap-2">
<Label for="id_key2">Forma de Identificación 2</Label>
<Select.Root type="single" bind:value={formData.id_key2}>
<Select.Trigger class="w-full" id="id_key2">
{FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key2)?.label || '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each FORMA_ID_OPCIONES as o}
<Select.Item value={o.value} label={o.value}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="id_number2">Núm. Identificación 2</Label>
<Input id="id_number2" bind:value={formData.id_number2} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="id_state2">Estado ID 2</Label>
<Input id="id_state2" bind:value={formData.id_state2} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="id_country2">País ID 2</Label>
<Select.Root type="single" bind:value={formData.id_country2} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="id_country2">
{formData.id_country2 ? `${formData.id_country2} ${countries.find((c) => c.ame_key === formData.id_country2)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key}{c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>

View File

@@ -213,7 +213,7 @@
bind:value={formData.transporter_key}
disabled={isEdit}
required
maxlength={23}
maxlength={30}
/>
</div>
@@ -234,12 +234,12 @@
<div class="grid gap-2">
<Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} />
<Input id="rfc" bind:value={formData.rfc} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} />
<Input id="responsible" bind:value={formData.responsible} maxlength={100} />
</div>
</section>
@@ -249,7 +249,7 @@
<div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} />
<Input id="caat_code" bind:value={formData.caat_code} maxlength={49} />
</div>
<div class="grid gap-2">
@@ -293,7 +293,7 @@
<div class="grid gap-2">
<Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} />
<Input id="streets" bind:value={formData.streets} maxlength={100} />
</div>
<div class="grid grid-cols-2 gap-4">
@@ -345,7 +345,7 @@
</div>
<div class="grid gap-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} />
<Input id="postal_code" bind:value={formData.postal_code} maxlength={15} />
</div>
</div>
</section>
@@ -356,23 +356,23 @@
<div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} />
<Input id="ftp_server" bind:value={formData.ftp_server} maxlength={200} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} />
<Input id="ftp_user" bind:value={formData.ftp_user} maxlength={200} />
</div>
<div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} maxlength={100} />
</div>
</div>
<div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
<Input id="ftp_directory" bind:value={formData.ftp_directory} maxlength={1000} />
</div>
</section>
</div>

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -12,7 +10,6 @@
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
@@ -21,34 +18,31 @@
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let createDialogOpen = $state(false);
let searchTransporterKey = $state(page.url.searchParams.get('transporter_key') || '');
let searchDriverName = $state(page.url.searchParams.get('driver_name') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
let searchTransporterKey = $state('');
let searchDriverName = $state('');
// Solo reacciona a cambios de URL (p. ej. atrás/adelante); no leer los campos de búsqueda aquí para no pisar lo que escribe el usuario.
$effect(() => {
const u = page.url;
searchTransporterKey = u.searchParams.get('transporter_key') || '';
searchDriverName = u.searchParams.get('driver_name') || '';
});
let filteredData = $derived(
data.filter((d) => {
const tk = searchTransporterKey.trim().toLowerCase();
const dn = searchDriverName.trim().toLowerCase();
if (tk && !(d.transporter_key ?? '').toLowerCase().includes(tk)) return false;
if (dn && !(d.driver_name ?? '').toLowerCase().includes(dn)) return false;
return true;
})
);
let hasMore = $derived(data.length < totalItems);
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const params: Record<string, string | number> = {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize
};
// Filtros: el backend aún no los soporta; se mantienen en URL para futura implementación
// if (searchTransporterKey) params.transporter_key = searchTransporterKey;
// if (searchDriverName) params.driver_name = searchDriverName;
const response = await driversApi.list(companyStore.activeCompany.id, params);
});
if (response.data) {
data = response.data.items;
currentPage = 1;
@@ -65,11 +59,10 @@
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const params: Record<string, string | number> = {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize
};
const response = await driversApi.list(companyStore.activeCompany.id, params);
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
@@ -82,21 +75,8 @@
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL(page.url);
if (searchTransporterKey) url.searchParams.set('transporter_key', searchTransporterKey);
else url.searchParams.delete('transporter_key');
if (searchDriverName) url.searchParams.set('driver_name', searchDriverName);
else url.searchParams.delete('driver_name');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
$effect(() => {
const _ = { p: page.url.href, c: companyStore.activeCompany?.id };
const _c = companyStore.activeCompany?.id;
loadData();
});
@@ -130,11 +110,11 @@
</div>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Conductores</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave transportista" class="h-9 w-44 bg-card lg:w-56" bind:value={searchTransporterKey} oninput={handleSearch} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchDriverName} oninput={handleSearch} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando conductores...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} /></div>{/if}</Card.Content>
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Conductores</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave transportista" class="h-9 w-44 bg-card lg:w-56" bind:value={searchTransporterKey} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchDriverName} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando conductores...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable data={filteredData} {columns} {loading} {hasMore} {loadMore} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">Mostrando {data.length} de {totalItems} registros</div>
<div class="flex-none text-sm text-muted-foreground">Mostrando {filteredData.length} de {totalItems} registros</div>
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>