Files
plantillas-proyectos/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte

564 lines
21 KiB
Svelte

<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';
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { countriesApi, type Country } from '$lib/api/dashboard/reference_data/countries';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Driver | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Conductor' : 'Nuevo Conductor');
let transporters = $state<Transporter[]>([]);
let transportersLoading = $state(false);
let countries = $state<Country[]>([]);
let countriesLoading = $state(false);
// 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);
// 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) => {
transporters = res.data?.items ?? [];
})
.catch(() => (transporters = []))
.finally(() => (transportersLoading = false));
}
if (open && browser) {
countriesLoading = true;
countriesApi
.list(1, 100)
.then((res) => {
countries = res.data?.items ?? [];
})
.catch(() => (countries = []))
.finally(() => (countriesLoading = false));
}
});
$effect(() => {
if (!open) {
error = null;
loading = false;
return;
}
formData = item ? fromItem(item) : emptyForm();
});
async function handleSubmit() {
if (loading) return;
error = null;
// 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');
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,
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) {
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) {
error = e instanceof Error ? e.message : 'Error al guardar el conductor';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
function onSaveFormEvent() {
void handleSubmit();
}
$effect(() => {
if (!browser) return;
document.addEventListener('save-form', onSaveFormEvent);
return () => document.removeEventListener('save-form', onSaveFormEvent);
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{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-4">
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">{error}</div>
{/if}
<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>
<!-- Tab 1: Generales -->
<Tabs.Content value="generales" class="mt-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>