Merge branch 'development' into feature/digitalizacion-api
This commit is contained in:
@@ -26,6 +26,65 @@ export function humanizeLineReferences(text: string): string {
|
||||
return text.replace(/\bline\[(\d+)\]/gi, 'partida $1');
|
||||
}
|
||||
|
||||
function humanizeFieldPath(field: string): string {
|
||||
const rawField = (field || '').trim();
|
||||
if (!rawField) return 'campo';
|
||||
|
||||
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
|
||||
const fieldPath = lineMatch?.[2] || rawField;
|
||||
const label = fieldPath
|
||||
.replace(/^body\./i, '')
|
||||
.replace(/\./g, ' → ')
|
||||
.replace(/_/g, ' ');
|
||||
|
||||
if (lineMatch) {
|
||||
return `Partida ${lineMatch[1]} - ${label}`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function humanizeValidationMessage(message: string): string {
|
||||
const rawMessage = (message || '').trim();
|
||||
if (!rawMessage) return 'error de validación';
|
||||
|
||||
return rawMessage
|
||||
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
|
||||
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
|
||||
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
|
||||
}
|
||||
|
||||
function formatValidationHint(field: string, message: string, code?: string): string {
|
||||
const fieldLabel = humanizeFieldPath(field);
|
||||
const normalizedMessage = humanizeValidationMessage(message);
|
||||
|
||||
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) {
|
||||
return `Completa ${fieldLabel}.`;
|
||||
}
|
||||
|
||||
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
|
||||
return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
|
||||
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
|
||||
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'CLASS_NOT_FOUND') {
|
||||
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'FRACTION_TYPE_INVALID') {
|
||||
return 'Selecciona un tipo de tarifa válido.';
|
||||
}
|
||||
|
||||
return normalizedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Título y descripción listos para toasts / alertas a partir de ApiResponse.
|
||||
* Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas.
|
||||
@@ -34,7 +93,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
|
||||
const validationErrors = res.validationErrors;
|
||||
if (validationErrors?.length) {
|
||||
const blocks = validationErrors.map((e) => {
|
||||
const base = humanizeLineReferences((e.message || '').trim() || e.field);
|
||||
const base = formatValidationHint(e.field || '', e.message || '', e.code);
|
||||
const hints = e.solution?.filter(Boolean).length
|
||||
? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n')
|
||||
: '';
|
||||
@@ -52,7 +111,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
const err = humanizeLineReferences(res.error.trim());
|
||||
const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim()));
|
||||
if (err.startsWith('Error de validación:')) {
|
||||
return {
|
||||
title: 'Revisa los datos ingresados',
|
||||
@@ -241,6 +300,7 @@ async function fetchApi<T = any>(
|
||||
if (response.status === 422) {
|
||||
// HTTPException(detail={ message, errors }) — catálogo / CSV parity
|
||||
const det = data.detail;
|
||||
const validationErrors = (errors: unknown[]) => errors as NonNullable<ApiResponse['validationErrors']>;
|
||||
if (
|
||||
det &&
|
||||
typeof det === 'object' &&
|
||||
@@ -250,7 +310,7 @@ async function fetchApi<T = any>(
|
||||
const d = det as { message?: string; errors: unknown[] };
|
||||
return {
|
||||
error: d.message || 'Error de validación',
|
||||
validationErrors: d.errors,
|
||||
validationErrors: validationErrors(d.errors),
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
@@ -258,7 +318,7 @@ async function fetchApi<T = any>(
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
return {
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors,
|
||||
validationErrors: validationErrors(data.errors),
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
@@ -421,7 +481,7 @@ async function fetchApiFormDataPost<T = any>(
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
resolve({
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors,
|
||||
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
|
||||
status: 422
|
||||
});
|
||||
return;
|
||||
@@ -431,8 +491,8 @@ async function fetchApiFormDataPost<T = any>(
|
||||
if (Array.isArray(data.detail)) {
|
||||
const errors = data.detail
|
||||
.map((err: any) => {
|
||||
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
|
||||
return `${field}: ${err.msg}`;
|
||||
const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido';
|
||||
return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`;
|
||||
})
|
||||
.join(', ');
|
||||
errorMessage += errors;
|
||||
@@ -519,6 +579,12 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||
postBlob: (endpoint: string, body: any) =>
|
||||
fetchBlob(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface DownloadedPartsReportSection {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DownloadedPartsReportBootstrap {
|
||||
report_key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
status: string;
|
||||
available_filters: string[];
|
||||
next_steps: string[];
|
||||
sections: DownloadedPartsReportSection[];
|
||||
}
|
||||
|
||||
export interface DownloadedPartsReportRequest {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
class_from?: string;
|
||||
class_to?: string;
|
||||
print_class_mode: 'exported' | 'downloaded';
|
||||
exchange_rate_mode: 'invoice' | 'pedimento_payment';
|
||||
currency_mode: 'dollars' | 'pesos' | 'both';
|
||||
temporality_mode: 'temporales' | 'definitivos' | 'ambos';
|
||||
weight_type_mode: 'kilos' | 'libras' | 'ambos';
|
||||
operation_mode: 'importacion' | 'exportacion';
|
||||
material_type?: string;
|
||||
invoice_type?: string;
|
||||
parts?: string[];
|
||||
pedimento_key?: string;
|
||||
provider_id?: number;
|
||||
sold_to_id?: number;
|
||||
shipped_to_id?: number;
|
||||
destination_customs?: string;
|
||||
include_series: boolean;
|
||||
print_class_total: boolean;
|
||||
include_totals_by_fraction: boolean;
|
||||
julian_date: boolean;
|
||||
show_item_description: boolean;
|
||||
include_exempt_fraction: boolean;
|
||||
show_export_fraction: boolean;
|
||||
include_rule_octava: boolean;
|
||||
include_american_fraction_and_country: boolean;
|
||||
respect_import_invoice_value_in_pesos: boolean;
|
||||
show_all_temporary_balances: boolean;
|
||||
}
|
||||
|
||||
export const downloadedPartsReportsApi = {
|
||||
getBootstrap: (companyId: number) =>
|
||||
api.get<DownloadedPartsReportBootstrap>(
|
||||
`/v1/a76/reports/exportacion/partes-descargadas/bootstrap?company_id=${companyId}`
|
||||
),
|
||||
|
||||
generate: async (
|
||||
companyId: number,
|
||||
params: DownloadedPartsReportRequest
|
||||
): Promise<void> => {
|
||||
const blob = await api.postBlob(
|
||||
`/v1/a76/reports/exportacion/partes-descargadas/generate?company_id=${companyId}`,
|
||||
params
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `partes_descargadas_${params.date_from}_${params.date_to}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -3,13 +3,15 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { FolderSearch, Scale } from 'lucide-svelte';
|
||||
import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Scale } from 'lucide-svelte';
|
||||
import {
|
||||
createEquivalencyItem,
|
||||
updateEquivalencyItem,
|
||||
type EquivalencyItem
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
|
||||
import type { UnitOfMeasure } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -37,25 +39,33 @@
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let showOriginalModal = $state(false);
|
||||
let showExternalModal = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (item) {
|
||||
formData = {
|
||||
original_field: item.original_field || '',
|
||||
external_field: item.external_field || ''
|
||||
};
|
||||
formData.original_field = item.original_field || '';
|
||||
formData.external_field = item.external_field || '';
|
||||
} else {
|
||||
formData = {
|
||||
original_field: defaultOriginalField ?? '',
|
||||
external_field: ''
|
||||
};
|
||||
formData.original_field = defaultOriginalField ?? '';
|
||||
formData.external_field = '';
|
||||
}
|
||||
|
||||
error = null;
|
||||
});
|
||||
|
||||
function handleSelectOriginal(unit: UnitOfMeasure) {
|
||||
formData.original_field = unit.code;
|
||||
showOriginalModal = false;
|
||||
}
|
||||
|
||||
function handleSelectExternal(unit: UnitOfMeasure) {
|
||||
formData.external_field = unit.code;
|
||||
showExternalModal = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
@@ -113,40 +123,64 @@
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="from_unit_code">
|
||||
<Label for="original_field">
|
||||
Campo Original <span class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative w-full">
|
||||
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="from_unit_code"
|
||||
bind:value={formData.original_field}
|
||||
class="pl-9 font-mono"
|
||||
placeholder="Ej: PZA, KGM..."
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="original_field"
|
||||
bind:value={formData.original_field}
|
||||
readonly
|
||||
onclick={() => (showOriginalModal = true)}
|
||||
class="cursor-pointer pl-9 font-mono"
|
||||
placeholder="Seleccione..."
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showOriginalModal = true)}
|
||||
disabled={loading}
|
||||
class="shrink-0"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="to_unit_code">
|
||||
<Label for="external_field">
|
||||
Campo Exterior <span class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative w-full">
|
||||
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="to_unit_code"
|
||||
bind:value={formData.external_field}
|
||||
class="pl-9 font-mono"
|
||||
placeholder="Ej: PIEZAS, KGS..."
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="external_field"
|
||||
bind:value={formData.external_field}
|
||||
readonly
|
||||
onclick={() => (showExternalModal = true)}
|
||||
class="cursor-pointer pl-9 font-mono"
|
||||
placeholder="Seleccione..."
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onclick={() => (showExternalModal = true)}
|
||||
disabled={loading}
|
||||
class="shrink-0"
|
||||
>
|
||||
<FolderSearch class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,3 +194,6 @@
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<UnitMeasureSelectorDialog bind:open={showOriginalModal} onSelect={handleSelectOriginal} />
|
||||
<UnitMeasureSelectorDialog bind:open={showExternalModal} onSelect={handleSelectExternal} />
|
||||
|
||||
@@ -21,32 +21,55 @@
|
||||
let items = $state<USTariffFraction[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
let loadedForCompanyId = $state<number | null>(null);
|
||||
|
||||
const activeCompanyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
function normalizeAmericanFractionCode(code: string) {
|
||||
return (code || '').replace(/[.\s-]/g, '');
|
||||
}
|
||||
|
||||
function isEligibleAmericanFraction(item: USTariffFraction) {
|
||||
const normalizedCode = normalizeAmericanFractionCode(item.code || '');
|
||||
return /^\d{8}$/.test(normalizedCode) || /^\d{10}$/.test(normalizedCode);
|
||||
}
|
||||
|
||||
// Filtro local
|
||||
let filteredItems = $derived(
|
||||
items.filter(i =>
|
||||
(i.code || "").includes(searchTerm) ||
|
||||
isEligibleAmericanFraction(i) &&
|
||||
((i.code || "").includes(searchTerm) ||
|
||||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
loadFractions();
|
||||
if (!open) return;
|
||||
|
||||
if (!activeCompanyId) {
|
||||
items = [];
|
||||
loadedForCompanyId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedForCompanyId !== activeCompanyId) {
|
||||
searchTerm = '';
|
||||
items = [];
|
||||
void loadFractions(activeCompanyId);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFractions() {
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
async function loadFractions(companyId: number) {
|
||||
if (!companyId) {
|
||||
toast.error("No hay empresa seleccionada");
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id);
|
||||
const response = await getUSTariffFractions(1, 1000, companyId);
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error al cargar fracciones americanas:", response.error);
|
||||
@@ -55,8 +78,8 @@
|
||||
}
|
||||
|
||||
if (response.data?.items) {
|
||||
items = response.data.items;
|
||||
loaded = true;
|
||||
items = response.data.items.filter((item) => isEligibleAmericanFraction(item));
|
||||
loadedForCompanyId = companyId;
|
||||
} else {
|
||||
console.warn("No se encontraron fracciones americanas:", response);
|
||||
toast.info("No se encontraron fracciones americanas registradas");
|
||||
|
||||
@@ -42,14 +42,9 @@
|
||||
invoice_number: searchTerm || undefined,
|
||||
status: status || undefined
|
||||
};
|
||||
|
||||
if (operationType === 'imp' && regimen) {
|
||||
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
|
||||
filters.invoice_type = 'TEM';
|
||||
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
filters.invoice_type = 'DEF';
|
||||
}
|
||||
}
|
||||
// Do NOT filter by invoice_type here: restricting to TEM or DEF based on
|
||||
// the current movement_type_import value would hide valid invoices of the
|
||||
// other type. Let the user search freely and pick the right one.
|
||||
|
||||
console.log('🔍 [Modal] Buscando facturas...', { activeCompanyId, filters });
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
if (editingItem.fa_data.discharge === undefined) {
|
||||
editingItem.fa_data.discharge = false;
|
||||
}
|
||||
if ((editingItem.fa_data.movement_type_import === undefined || editingItem.fa_data.movement_type_import === '') && (showLinkToImportBlock || showRepairBlock)) {
|
||||
editingItem.fa_data.movement_type_import = 'TEM';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -196,10 +199,12 @@
|
||||
if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
|
||||
// Do NOT filter by invoice_type: if movement_type_import is null or
|
||||
// mismatched the invoice won't be found, leaving the line picker
|
||||
// permanently disabled. Exact match is enforced by .find() below.
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
|
||||
operation_type: 'imp',
|
||||
invoice_number: num,
|
||||
invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM'
|
||||
invoice_number: num
|
||||
});
|
||||
const items = res.data?.items ?? [];
|
||||
const inv = items.find((i: Invoice) => i.invoice_number === num);
|
||||
@@ -215,7 +220,7 @@
|
||||
if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
|
||||
operation_type: 'exp',
|
||||
invoice_number: num
|
||||
});
|
||||
@@ -357,6 +362,7 @@
|
||||
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
|
||||
<p class="text-[10px] text-muted-foreground -mt-1">Los campos marcados con * son obligatorios.</p>
|
||||
<RadioGroup
|
||||
value={editingItem.fa_data?.discharge === false ? 'no' : 'si'}
|
||||
onValueChange={(v) => {
|
||||
@@ -483,7 +489,7 @@
|
||||
<!-- Fila ligada: selector de factura (FK) + línea (FK) -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-[100px] flex-1 space-y-1">
|
||||
<Label class="text-xs">Tipo Importación</Label>
|
||||
<Label class="text-xs">Tipo Importación: <span class="text-red-500">*</span></Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={editingItem.fa_data?.movement_type_import || 'TEM'}
|
||||
|
||||
@@ -331,6 +331,9 @@
|
||||
|
||||
<fieldset class="border rounded-md p-3">
|
||||
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Main Data</legend>
|
||||
<p class="mt-1 px-1 text-[10px] text-muted-foreground">
|
||||
Los campos marcados con * son obligatorios.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Class - Full Width -->
|
||||
@@ -448,7 +451,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for="fraccion" class="text-xs font-medium">Fracción:</Label>
|
||||
<Label for="fraccion" class="text-xs font-medium">Fracción: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraccion"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import PackageDialog from './package-dialog.svelte';
|
||||
import USFractionSelectorDialog from '$lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte';
|
||||
|
||||
let {
|
||||
item = $bindable(),
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
|
||||
let packageDialogOpen = $state(false);
|
||||
let americanFractionDialogOpen = $state(false);
|
||||
let package_key = $state('');
|
||||
let package_weight_unit = $state<number>(0);
|
||||
let isLoadingPackage = $state(false);
|
||||
@@ -114,19 +116,30 @@
|
||||
package_weight_unit = pkg.weight_unit || 0;
|
||||
quantities.package_description = pkg.description_es || pkg.description_en || pkg.key;
|
||||
}
|
||||
|
||||
function handleAmericanFractionSelect(fraction: any) {
|
||||
customs.american_fraction = fraction.code || '';
|
||||
(customs as any).american_fraction_description = fraction.description || '';
|
||||
if (fraction.ad_valorem !== null && fraction.ad_valorem !== undefined) {
|
||||
customs.advalorem_american = fraction.ad_valorem;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="border rounded-md p-2 space-y-2">
|
||||
<legend class="text-xs font-semibold px-2 bg-gray-200 dark:bg-gray-700">PACKAGES</legend>
|
||||
<p class="text-[10px] text-muted-foreground px-1">
|
||||
Los campos marcados con * son obligatorios.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-12 gap-2 items-end">
|
||||
<div class="col-span-2 space-y-1">
|
||||
<Label for="cantidad_bultos" class="text-xs">Quantity:</Label>
|
||||
<Label for="cantidad_bultos" class="text-xs">Quantity: <span class="text-red-500">*</span></Label>
|
||||
<Input id="cantidad_bultos" type="number" step="1" min="0" bind:value={quantities.package_quantity} disabled={disabled} class="h-7 text-xs text-right" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Label for="clave_bultos" class="text-xs">Package Code:</Label>
|
||||
<Label for="clave_bultos" class="text-xs">Package Code: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="clave_bultos"
|
||||
@@ -169,7 +182,7 @@
|
||||
<div class="text-xs font-semibold mb-2">WEIGHTS</div>
|
||||
<div class="grid grid-cols-6 gap-2 items-end">
|
||||
<div class="col-span-2 space-y-1">
|
||||
<Label for="peso_neto" class="text-xs">Net:</Label>
|
||||
<Label for="peso_neto" class="text-xs">Net: <span class="text-red-500">*</span></Label>
|
||||
<Input id="peso_neto" type="number" step="0.00000001" min="0" bind:value={quantities.net_weight} disabled={disabled} class="h-7 text-xs text-right" />
|
||||
</div>
|
||||
|
||||
@@ -200,8 +213,28 @@
|
||||
|
||||
<div class="grid grid-cols-12 gap-2 items-end">
|
||||
<div class="col-span-4 space-y-1">
|
||||
<Label for="fraccion_americana" class="text-xs">American Fraction:</Label>
|
||||
<Input id="fraccion_americana" bind:value={customs.american_fraction} disabled={disabled} class="h-7 text-xs" />
|
||||
<Label for="fraccion_americana" class="text-xs">American Fraction: <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraccion_americana"
|
||||
value={customs.american_fraction || ''}
|
||||
readonly
|
||||
disabled={disabled}
|
||||
class="h-7 text-xs flex-1 bg-muted cursor-pointer"
|
||||
placeholder="Seleccionar..."
|
||||
onclick={() => !disabled && (americanFractionDialogOpen = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
disabled={disabled}
|
||||
onclick={() => !disabled && (americanFractionDialogOpen = true)}
|
||||
>
|
||||
<Folder class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 space-y-1">
|
||||
@@ -230,3 +263,4 @@
|
||||
</fieldset>
|
||||
|
||||
<PackageDialog bind:open={packageDialogOpen} onSelect={handlePackageSelect} />
|
||||
<USFractionSelectorDialog bind:open={americanFractionDialogOpen} onSelect={handleAmericanFractionSelect} />
|
||||
|
||||
@@ -225,6 +225,9 @@
|
||||
|
||||
{#if editingItem}
|
||||
<div class="max-h-[calc(90vh-96px)] overflow-auto bg-slate-50/60 p-6 dark:bg-black">
|
||||
<p class="mb-3 text-[10px] text-muted-foreground">
|
||||
Los campos marcados con * son obligatorios.
|
||||
</p>
|
||||
<Tabs.Root bind:value={activeTab} class="mt-0">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
@@ -272,7 +275,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="class_code">Clase</Label>
|
||||
<Label for="class_code">Clase <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="class_code"
|
||||
@@ -295,13 +298,13 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="quantity_general">Cantidad</Label>
|
||||
<Label for="quantity_general">Cantidad <span class="text-red-500">*</span></Label>
|
||||
{#if editingItem?.quantity}
|
||||
<Input id="quantity_general" type="number" step="0.00000001" min="0" bind:value={editingItem.quantity.quantity} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="unit_general">U.M.</Label>
|
||||
<Label for="unit_general">U.M. <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="unit_general"
|
||||
@@ -321,13 +324,13 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="unit_cost_capture">Costo Unitario</Label>
|
||||
<Label for="unit_cost_capture">Costo Unitario <span class="text-red-500">*</span></Label>
|
||||
{#if editingItem?.financial}
|
||||
<Input id="unit_cost_capture" type="number" step="0.00000001" min="0" bind:value={editingItem.financial.unit_cost_capture} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="origin_country_general">País de Origen</Label>
|
||||
<Label for="origin_country_general">País de Origen <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="origin_country_general"
|
||||
@@ -347,7 +350,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="fraction_general">Fracción</Label>
|
||||
<Label for="fraction_general">Fracción <span class="text-red-500">*</span></Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fraction_general"
|
||||
@@ -366,7 +369,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fraction_type_general">Tipo de Tarifa</Label>
|
||||
<Label for="fraction_type_general">Tipo de Tarifa <span class="text-red-500">*</span></Label>
|
||||
{#if editingItem?.customs}
|
||||
<select id="fraction_type_general" bind:value={editingItem.customs.fraction_type} class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<option value=""></option>
|
||||
@@ -464,7 +467,7 @@
|
||||
<Tabs.Content value="clasificacion" class="mt-4 space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="tariff_fraction">Fracción Arancelaria</Label>
|
||||
<Label for="tariff_fraction">Fracción Arancelaria <span class="text-red-500">*</span></Label>
|
||||
{#if editingItem?.customs}
|
||||
<Input
|
||||
id="tariff_fraction"
|
||||
@@ -504,7 +507,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="country_origin">País de Origen</Label>
|
||||
<Label for="country_origin">País de Origen <span class="text-red-500">*</span></Label>
|
||||
{#if editingItem?.customs}
|
||||
<Input id="country_origin" placeholder="Código del país" bind:value={editingItem.customs.origin_country} />
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import type { Sector } from "./columns.js";
|
||||
import CreateEditDialog from "./create-edit-dialog.svelte";
|
||||
import DetailsDialog from "./details-dialog.svelte";
|
||||
import DeleteDialog from "./delete-dialog.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -17,7 +16,6 @@
|
||||
|
||||
let showDetailsDialog = $state(false);
|
||||
let showEditDialog = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
|
||||
function handleCopyId() {
|
||||
navigator.clipboard.writeText(item.key.toString());
|
||||
@@ -30,10 +28,6 @@
|
||||
function handleEdit() {
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -55,12 +49,9 @@
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<DetailsDialog bind:open={showDetailsDialog} {item} />
|
||||
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
|
||||
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />
|
||||
|
||||
@@ -10,7 +10,7 @@ export type State = {
|
||||
ame_key?: string | null;
|
||||
};
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<State>[] {
|
||||
export function createColumns(onSuccess?: () => void, readOnly = false): ColumnDef<State>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "m3_key",
|
||||
@@ -74,11 +74,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef<State>[] {
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, { item: row.original, onSuccess });
|
||||
return renderComponent(DataTableActions, { item: row.original, onSuccess, readOnly });
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Mantener compatibilidad hacia atrás
|
||||
export const columns = createColumns();
|
||||
export const columns = createColumns(undefined, true);
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
onSuccess,
|
||||
readOnly = false
|
||||
}: {
|
||||
item: State;
|
||||
onSuccess?: () => void;
|
||||
readOnly?: boolean;
|
||||
} = $props();
|
||||
|
||||
let showDetailsDialog = $state(false);
|
||||
@@ -54,13 +56,17 @@
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
|
||||
{#if !readOnly}
|
||||
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<DetailsDialog bind:open={showDetailsDialog} {item} />
|
||||
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
|
||||
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />
|
||||
{#if !readOnly}
|
||||
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
|
||||
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -508,6 +508,10 @@ export function getSidebarData(): SidebarData {
|
||||
title: "Facturas Impo/Expo",
|
||||
url: "/dashboard/reports/invoices",
|
||||
},
|
||||
{
|
||||
title: "Partes descargadas",
|
||||
url: "/dashboard/reports/partes-descargadas",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Calendar } from 'lucide-svelte';
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
@@ -18,6 +19,19 @@
|
||||
"data-slot": dataSlot = "input",
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const isDateInput = $derived(type === 'date' || type === 'datetime-local');
|
||||
|
||||
function openDatePicker() {
|
||||
if (!ref) return;
|
||||
|
||||
if ('showPicker' in ref && typeof ref.showPicker === 'function') {
|
||||
ref.showPicker();
|
||||
return;
|
||||
}
|
||||
|
||||
ref.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
@@ -35,6 +49,31 @@
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else if isDateInput}
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 pr-11 text-base text-foreground outline-none transition-[color,box-shadow] appearance-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
"[&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0"
|
||||
)}
|
||||
type={type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abrir selector de fecha"
|
||||
onclick={openDatePicker}
|
||||
class="absolute top-1/2 right-1 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Calendar aria-hidden="true" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
|
||||
@@ -259,12 +259,103 @@ const FIELD_MAP: Record<string, string> = {
|
||||
'financial.unit_cost_capture': 'Costo Unitario',
|
||||
'customs.fraction': 'Fracción Arancelaria',
|
||||
'customs.origin_country': 'País de Origen',
|
||||
'customs.american_fraction': 'Fracción Americana',
|
||||
'fa_data.search_invoice': 'Factura de Referencia',
|
||||
'fa_data.search_line': 'Línea de Referencia',
|
||||
'fa_data.search_type': 'Tipo de Búsqueda',
|
||||
'fa_data.movement_type_import': 'Tipo de Importación'
|
||||
'fa_data.movement_type_import': 'Tipo de Importación',
|
||||
'fa_data.is_subitem': 'Es Subpartida',
|
||||
'fa_data.subitem_number': 'Número de Partida Principal'
|
||||
};
|
||||
|
||||
const FIELD_GUIDANCE: Record<string, string> = {
|
||||
class_id: 'Selecciona una clase.',
|
||||
unit_of_measure: 'Selecciona una unidad de medida.',
|
||||
'quantity.quantity': 'Captura una cantidad válida mayor a cero.',
|
||||
'quantity.net_weight': 'Captura un peso neto válido mayor a cero.',
|
||||
'customs.fraction': 'Selecciona una fracción arancelaria válida.',
|
||||
'customs.origin_country': 'Selecciona un país de origen válido.',
|
||||
'customs.fraction_type': 'Selecciona un tipo de tarifa.',
|
||||
'customs.american_fraction': 'Selecciona una fracción americana válida.',
|
||||
'description.description_spanish': 'Captura la descripción en español.',
|
||||
'description.description_english': 'Captura la descripción en inglés.',
|
||||
'financial.unit_cost_capture': 'Captura un costo unitario válido.',
|
||||
'fa_data.search_invoice': 'Selecciona una factura de referencia.',
|
||||
'fa_data.search_line': 'Selecciona una línea de referencia.',
|
||||
'fa_data.search_type': 'Selecciona un tipo de búsqueda.',
|
||||
'fa_data.movement_type_import': 'Selecciona TEM o DEF.',
|
||||
'fa_data.subitem_number': 'Captura el número de la partida principal.'
|
||||
};
|
||||
|
||||
function humanizeFieldPath(field: string): string {
|
||||
const rawField = (field || '').trim();
|
||||
if (!rawField) return 'campo';
|
||||
|
||||
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
|
||||
const fieldPath = lineMatch?.[2] || rawField;
|
||||
const mappedPath = fieldPath.replace(/^body\./i, '');
|
||||
const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → ');
|
||||
|
||||
if (lineMatch) {
|
||||
return `Partida ${lineMatch[1]} - ${fieldLabel}`;
|
||||
}
|
||||
|
||||
return fieldLabel;
|
||||
}
|
||||
|
||||
function humanizeValidationMessage(message: string): string {
|
||||
const rawMessage = (message || '').trim();
|
||||
if (!rawMessage) return 'error de validación';
|
||||
|
||||
return rawMessage
|
||||
.replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => {
|
||||
return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`;
|
||||
})
|
||||
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
|
||||
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
|
||||
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
|
||||
}
|
||||
|
||||
function formatFriendlyFieldMessage(fieldName: string, message: string, code?: string): string {
|
||||
const cleanFieldName = fieldName.replace(/^Partida \d+ - /, '');
|
||||
const guidance = FIELD_GUIDANCE[cleanFieldName] || FIELD_GUIDANCE[fieldName];
|
||||
const normalizedMessage = humanizeValidationMessage(message);
|
||||
|
||||
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es requerido|es obligatorio/i.test(normalizedMessage)) {
|
||||
return guidance || `Completa ${fieldName}.`;
|
||||
}
|
||||
|
||||
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
|
||||
return `La fracción americana seleccionada no existe. Elige una opción del catálogo.`;
|
||||
}
|
||||
|
||||
if (code === 'FRACTION_TYPE_INVALID') {
|
||||
return 'Selecciona un tipo de tarifa válido.';
|
||||
}
|
||||
|
||||
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
|
||||
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
|
||||
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'CLASS_NOT_FOUND') {
|
||||
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'PACKAGE_NOT_FOUND' || code === 'PACKAGE_ID_REQUIRED') {
|
||||
return 'El paquete seleccionado no es válido. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'MOVEMENT_TYPE_IMPORT_INVALID') {
|
||||
return 'Selecciona TEM o DEF para el tipo de importación.';
|
||||
}
|
||||
|
||||
return normalizedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a backend error into a human-readable Spanish message.
|
||||
* Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error).
|
||||
@@ -285,12 +376,8 @@ export function formatItemError(error: any): string {
|
||||
// New structure (ApiResponse.validationErrors)
|
||||
if (status === 422 && Array.isArray(validationErrors)) {
|
||||
const errors = validationErrors.map((err: any) => {
|
||||
const field = err.field || '';
|
||||
const fieldName = FIELD_MAP[field] || field || 'campo';
|
||||
|
||||
let msg = err.message || 'error de validación';
|
||||
if (msg.includes('field required')) msg = 'es obligatorio';
|
||||
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
|
||||
const fieldName = humanizeFieldPath(err.field || '');
|
||||
const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code);
|
||||
|
||||
return `• ${fieldName}: ${msg}`;
|
||||
});
|
||||
@@ -305,11 +392,8 @@ export function formatItemError(error: any): string {
|
||||
.filter((l: string) => l !== 'body')
|
||||
.join('.');
|
||||
|
||||
const fieldName = FIELD_MAP[locPath] || locPath || 'campo';
|
||||
|
||||
let msg = err.msg || 'error de validación';
|
||||
if (msg.includes('field required')) msg = 'es obligatorio';
|
||||
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
|
||||
const fieldName = humanizeFieldPath(locPath);
|
||||
const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type);
|
||||
|
||||
return `• ${fieldName}: ${msg}`;
|
||||
});
|
||||
@@ -322,7 +406,7 @@ export function formatItemError(error: any): string {
|
||||
if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.';
|
||||
if (d.includes('not found')) return 'El registro no existe o fue eliminado.';
|
||||
if (d.includes('Class mismatch')) return 'Error de validación: ' + d;
|
||||
return d;
|
||||
return humanizeValidationMessage(d);
|
||||
}
|
||||
|
||||
// 4. Fallbacks by status code
|
||||
|
||||
Reference in New Issue
Block a user