fix/conductores-longitud-placa
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user