Ahora si guarda y edita de forma correcta

This commit is contained in:
2026-05-08 17:40:56 -05:00
parent 9bcbc5bc02
commit 06998f61d4
10 changed files with 400 additions and 92 deletions

View File

@@ -3,6 +3,7 @@ Carga de conjuntos FK para validación de import CSV de transportistas.
Clarion: GTransportista (ClaveTrans), GPaises (Pais_Ame), GEstados (Descripcion), relación Estado-País. Clarion: GTransportista (ClaveTrans), GPaises (Pais_Ame), GEstados (Descripcion), relación Estado-País.
""" """
from typing import Set, Tuple, Optional from typing import Set, Tuple, Optional
from sqlalchemy.orm import Session
import logging import logging
from core.database import CoreSessionLocal from core.database import CoreSessionLocal
@@ -13,6 +14,7 @@ logger = logging.getLogger(__name__)
def load_transportistas_fk_sets( def load_transportistas_fk_sets(
tenant_id: Optional[int] = None, tenant_id: Optional[int] = None,
company_id: Optional[int] = None, company_id: Optional[int] = None,
db: Optional[Session] = None,
) -> Tuple[ ) -> Tuple[
Set[str], Set[str],
Set[str], Set[str],
@@ -32,50 +34,56 @@ def load_transportistas_fk_sets(
state_descriptions_upper: Set[str] = set() state_descriptions_upper: Set[str] = set()
state_country_set: Set[Tuple[str, str]] = set() state_country_set: Set[Tuple[str, str]] = set()
try: def _load(session: Session):
with CoreSessionLocal() as session: from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.transporters.models import Transporter from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.states.models import State
if tenant_id is not None and company_id is not None: if tenant_id is not None and company_id is not None:
for row in ( for row in (
session.query(Transporter.transporter_key) session.query(Transporter.transporter_key)
.filter( .filter(
Transporter.tenant_id == tenant_id, Transporter.tenant_id == tenant_id,
Transporter.company_id == company_id, Transporter.company_id == company_id,
)
.all()
):
if row[0] and (row[0] or "").strip():
existing_transporter_keys.add((row[0] or "").strip().upper())
for row in session.query(Country.ame_key).all():
if row[0]:
valid_country_ame.add((row[0] or "").strip().upper())
for state in session.query(State).all():
country = (
session.query(Country)
.filter(Country.m3_key == state.m3_key)
.first()
) )
ame = None .all()
if country and (country.ame_key or "").strip(): ):
ame = (country.ame_key or "").strip().upper() if row[0] and (row[0] or "").strip():
existing_transporter_keys.add((row[0] or "").strip().upper())
desc = (state.description or "").strip() for row in session.query(Country.ame_key).all():
if desc: if row[0]:
state_descriptions_upper.add(desc.upper()) valid_country_ame.add((row[0] or "").strip().upper())
if ame:
state_country_set.add((ame, desc.upper()))
mex_key = (state.mex_key or "").strip() for state in session.query(State).all():
if mex_key: country = (
mk = mex_key.upper() session.query(Country)
state_descriptions_upper.add(mk) .filter(Country.m3_key == state.m3_key)
if ame: .first()
state_country_set.add((ame, mk)) )
ame = None
if country and (country.ame_key or "").strip():
ame = (country.ame_key or "").strip().upper()
desc = (state.description or "").strip()
if desc:
state_descriptions_upper.add(desc.upper())
if ame:
state_country_set.add((ame, desc.upper()))
mex_key = (state.mex_key or "").strip()
if mex_key:
mk = mex_key.upper()
state_descriptions_upper.add(mk)
if ame:
state_country_set.add((ame, mk))
try:
if db:
_load(db)
else:
with CoreSessionLocal() as session:
_load(session)
except Exception as e: except Exception as e:
logger.warning("Transportistas import: could not load FK sets: %s", e) logger.warning("Transportistas import: could not load FK sets: %s", e)

View File

@@ -37,24 +37,31 @@ def validate_row_transporter(
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
use_partial = actualizar and bool(clave and clave in existing) use_partial = actualizar and bool(clave and clave in existing)
if not use_partial and clave and clave in existing:
errors.append({
"line": line_num,
"col": "CLAVE TRANSPORTISTA",
"msg": f"La clave '{row.get('CLAVE TRANSPORTISTA')}' ya existe en el catálogo."
})
if use_partial: if use_partial:
errors.extend( errors.extend(
valida_parcial_transportistas( valida_parcial_transportistas(
row, row,
line_num, line_num,
valid_country_ame=valid_country_ame, valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper, state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set, state_country_set=state_country_set,
) )
) )
else: else:
errors.extend( errors.extend(
valida_toda_transportistas( valida_toda_transportistas(
row, row,
line_num, line_num,
valid_country_ame=valid_country_ame, valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper, state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set, state_country_set=state_country_set,
) )
) )
return errors return errors

View File

@@ -277,6 +277,7 @@ def transporter_model_to_row(t) -> Dict[str, Any]:
def validate_transporter_row_for_api( def validate_transporter_row_for_api(
db: Session,
tenant_id: int, tenant_id: int,
company_id: int, company_id: int,
row: Dict[str, Any], row: Dict[str, Any],
@@ -292,7 +293,7 @@ def validate_transporter_row_for_api(
valid_country_ame, valid_country_ame,
state_descriptions_upper, state_descriptions_upper,
state_country_set, state_country_set,
) = load_transportistas_fk_sets(tenant_id, company_id) ) = load_transportistas_fk_sets(tenant_id, company_id, db=db)
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
# existing set from loader is uppercased keys for this company # existing set from loader is uppercased keys for this company

View File

@@ -104,6 +104,7 @@ class TransporterService:
"""Create a new transporter""" """Create a new transporter"""
data = transporter_data.model_dump() data = transporter_data.model_dump()
validate_transporter_row_for_api( validate_transporter_row_for_api(
db,
tenant_id, tenant_id,
company_id, company_id,
transporter_fields_to_csv_row(data), transporter_fields_to_csv_row(data),
@@ -163,6 +164,7 @@ class TransporterService:
} }
merged.update(update_data) merged.update(update_data)
validate_transporter_row_for_api( validate_transporter_row_for_api(
db,
tenant_id, tenant_id,
company_id, company_id,
transporter_fields_to_csv_row(merged), transporter_fields_to_csv_row(merged),

View File

@@ -150,8 +150,13 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]:
yield db yield db
finally: finally:
db.close() db.close()
rls_tenant_var.reset(token_t) try:
rls_company_var.reset(token_c) rls_tenant_var.reset(token_t)
rls_company_var.reset(token_c)
except ValueError:
# Ignorar ValueError de contextvars en dependencias síncronas
# debido a que AnyIO puede ejecutar el teardown en un contexto diferente.
pass
async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]:
@@ -168,8 +173,11 @@ async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSess
finally: finally:
await session.close() await session.close()
finally: finally:
rls_tenant_var.reset(token_t) try:
rls_company_var.reset(token_c) rls_tenant_var.reset(token_t)
rls_company_var.reset(token_c)
except ValueError:
pass
@contextmanager @contextmanager

View File

@@ -331,18 +331,48 @@ async function fetchApi<T = any>(
// Manejo especial para errores 422 (validation error) // Manejo especial para errores 422 (validation error)
if (response.status === 422) { if (response.status === 422) {
// HTTPException(detail={ message, errors }) — catálogo / CSV parity // HTTPException(detail={ message, errors }) — catálogo / CSV parity
const det = data.detail; const det = data.detail || (typeof data.message === 'object' ? data.message : null);
const validationErrors = (errors: unknown[]) => errors as NonNullable<ApiResponse['validationErrors']>;
if ( if (
det && det &&
typeof det === 'object' && typeof det === "object" &&
!Array.isArray(det) && !Array.isArray(det) &&
Array.isArray((det as { errors?: unknown }).errors) Array.isArray((det as { errors?: unknown }).errors)
) { ) {
const d = det as { message?: string; errors: unknown[] }; const d = det as {
message?: string;
errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>;
};
// Mapping for catalog column names to DTO field names
const colToField: Record<string, string> = {
"CLAVE TRANSPORTISTA": "transporter_key",
NOMBRE: "name",
"NOMBRE CORTO": "short_name",
RESPONSABLE: "responsible",
RFC: "rfc",
CALLES: "streets",
"CODIGO POSTAL": "postal_code",
CIUDAD: "city",
ESTADO: "state",
PAIS: "country",
"CODIGO CARGADOR": "loader_code",
"CODIGO CAAT": "caat_code",
"CODIGO TRANS": "transport_code",
"TIPO INTERFASE TRANS": "transport_interface_type",
"SERVIDOR FTP": "ftp_server",
"USUARIO FTP": "ftp_user",
"CLAVE ACCESO FTP": "ftp_password",
"DIRECTORIO FTP": "ftp_directory"
};
const normalizedErrors = d.errors.map((err) => ({
field: err.field || (err.col ? colToField[err.col] || err.col : ""),
message: err.message || err.msg || "Error de validación"
}));
return { return {
error: d.message || 'Error de validación', error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'),
validationErrors: validationErrors(d.errors), validationErrors: normalizedErrors,
status: response.status status: response.status
}; };
} }
@@ -350,21 +380,31 @@ async function fetchApi<T = any>(
if (data.errors && Array.isArray(data.errors)) { if (data.errors && Array.isArray(data.errors)) {
return { return {
error: data.message || 'Error de validación', error: data.message || 'Error de validación',
validationErrors: validationErrors(data.errors), validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
status: response.status status: response.status
}; };
} }
// Errores de validación de FastAPI (con detail) // Errores de validación de FastAPI (con detail)
else if (data.detail) { else if (data.detail) {
let errorMessage = 'Error de validación: '; let errorMessage = 'Error de validación: ';
const vErrors: NonNullable<ApiResponse['validationErrors']> = [];
// FastAPI devuelve errores de validación en data.detail como array // FastAPI devuelve errores de validación en data.detail como array
if (Array.isArray(data.detail)) { if (Array.isArray(data.detail)) {
const errors = data.detail.map((err: any) => { data.detail.forEach((err: any) => {
const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo';
const msg = humanizeValidationMessage(err.msg || 'error de validación');
vErrors.push({
field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo',
message: msg
});
});
errorMessage += data.detail.map((err: any) => {
const field = err.loc ? err.loc.join('.') : 'campo desconocido'; const field = err.loc ? err.loc.join('.') : 'campo desconocido';
return `${field}: ${err.msg}`; return `${field}: ${err.msg}`;
}).join(', '); }).join(', ');
errorMessage += errors;
} else if (typeof data.detail === 'string') { } else if (typeof data.detail === 'string') {
errorMessage = data.detail; errorMessage = data.detail;
} else { } else {
@@ -373,6 +413,7 @@ async function fetchApi<T = any>(
return { return {
error: errorMessage, error: errorMessage,
validationErrors: vErrors.length ? vErrors : undefined,
status: response.status status: response.status
}; };
} }

View File

@@ -288,7 +288,7 @@
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl"> <Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{title}</Dialog.Title> <Dialog.Title>{title}</Dialog.Title>
<Dialog.Description> <Dialog.Description>

View File

@@ -168,7 +168,7 @@
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl"> <Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{title}</Dialog.Title> <Dialog.Title>{title}</Dialog.Title>
<Dialog.Description> <Dialog.Description>

View File

@@ -52,6 +52,7 @@
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let fieldErrors = $state<Record<string, string>>({});
let countries = $state<Country[]>([]); let countries = $state<Country[]>([]);
let states = $state<State[]>([]); let states = $state<State[]>([]);
let refsLoading = $state(false); let refsLoading = $state(false);
@@ -109,6 +110,7 @@
$effect(() => { $effect(() => {
if (!open) { if (!open) {
error = null; error = null;
fieldErrors = {};
loading = false; loading = false;
return; return;
} }
@@ -120,9 +122,89 @@
void loadReferenceData(); void loadReferenceData();
}); });
function validateForm(): boolean {
const errors: Record<string, string> = {};
// Clave del transportista
if (!formData.transporter_key?.trim()) {
errors.transporter_key = 'La clave es obligatoria';
} else if (/\s/.test(formData.transporter_key)) {
errors.transporter_key = 'La clave no puede contener espacios';
} else if (!/^[A-Za-z0-9_-]+$/.test(formData.transporter_key)) {
errors.transporter_key = 'La clave solo permite letras, números, guiones y guiones bajos';
}
// Nombre / Razón Social
if (!formData.name?.trim()) {
errors.name = 'El nombre o razón social es obligatorio';
}
// RFC (Opcional, pero si se pone debe ser válido si es MX)
if (formData.rfc?.trim()) {
const rfcRegex =
/^([A-ZÑ&]{3,4}) ?(?:- ?)?(\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])) ?(?:- ?)?([A-Z\d]{2})([A-Z\d])$/i;
if (formData.country === 'MEX' && !rfcRegex.test(formData.rfc.trim())) {
errors.rfc = 'Formato de RFC inválido para México';
}
}
// Código Postal
if (formData.postal_code?.trim()) {
if (formData.country === 'MEX' && !/^\d{5}$/.test(formData.postal_code.trim())) {
errors.postal_code = 'El código postal en México debe ser de 5 dígitos';
} else if (!/^\d+$/.test(formData.postal_code.trim())) {
errors.postal_code = 'El código postal debe ser numérico';
}
}
// Códigos de transporte
if (formData.caat_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.caat_code)) {
errors.caat_code = 'El código CAAT debe ser alfanumérico';
}
if (formData.transport_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.transport_code)) {
errors.transport_code = 'El código de transporte debe ser alfanumérico';
}
if (formData.loader_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.loader_code)) {
errors.loader_code = 'El código de cargador debe ser alfanumérico';
}
if (formData.filler_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.filler_code)) {
errors.filler_code = 'El código de relleno debe ser alfanumérico';
}
// Configuración FTP
if (formData.ftp_server?.trim()) {
const hostRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!hostRegex.test(formData.ftp_server) && !ipRegex.test(formData.ftp_server)) {
errors.ftp_server = 'Servidor FTP inválido (debe ser un host o IP)';
}
}
if (formData.ftp_user?.trim() && /\s/.test(formData.ftp_user)) {
errors.ftp_user = 'El usuario FTP no puede contener espacios';
}
fieldErrors = errors;
return Object.keys(errors).length === 0;
}
function clearFieldError(field: string) {
if (fieldErrors[field]) {
fieldErrors[field] = '';
}
}
async function handleSubmit() { async function handleSubmit() {
if (loading) return; if (loading) return;
error = null; error = null;
fieldErrors = {};
if (!validateForm()) {
error = 'Por favor, corrige los errores en el formulario';
return;
}
loading = true; loading = true;
try { try {
@@ -131,11 +213,6 @@
throw new Error('No hay una compañía seleccionada'); throw new Error('No hay una compañía seleccionada');
} }
// Validación básica
if (!formData.transporter_key.trim()) {
throw new Error('La clave es requerida');
}
let response; let response;
if (isEdit && item) { if (isEdit && item) {
response = await transportersApi.update(item.transporter_key, formData, companyId); response = await transportersApi.update(item.transporter_key, formData, companyId);
@@ -144,9 +221,17 @@
} }
if (response.error) { if (response.error) {
const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors; const ve = response.validationErrors;
if (ve?.length) { if (ve?.length) {
throw new Error(ve.map((e) => e.msg).join(' · ')); // Mapear errores de validación del backend si están disponibles
const backendErrors: Record<string, string> = {};
ve.forEach((err) => {
if (err.field) {
backendErrors[err.field] = err.message || 'Error de validación';
}
});
fieldErrors = backendErrors;
throw new Error('Errores de validación en el servidor');
} }
throw new Error(response.error); throw new Error(response.error);
} }
@@ -180,7 +265,7 @@
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl"> <Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{title}</Dialog.Title> <Dialog.Title>{title}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
@@ -214,14 +299,28 @@
id="transporter_key" id="transporter_key"
bind:value={formData.transporter_key} bind:value={formData.transporter_key}
disabled={isEdit} disabled={isEdit}
aria-invalid={!!fieldErrors.transporter_key}
oninput={() => clearFieldError('transporter_key')}
required required
maxlength={30} maxlength={30}
/> />
{#if fieldErrors.transporter_key}
<p class="text-xs text-destructive">{fieldErrors.transporter_key}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Nombre / Razón Social</Label> <Label for="name">Nombre / Razón Social <span class="text-destructive">*</span></Label>
<Input id="name" bind:value={formData.name} maxlength={256} /> <Input
id="name"
bind:value={formData.name}
aria-invalid={!!fieldErrors.name}
oninput={() => clearFieldError('name')}
maxlength={256}
/>
{#if fieldErrors.name}
<p class="text-xs text-destructive">{fieldErrors.name}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -229,19 +328,42 @@
<Input <Input
id="short_name" id="short_name"
bind:value={formData.short_name} bind:value={formData.short_name}
aria-invalid={!!fieldErrors.short_name}
oninput={() => clearFieldError('short_name')}
maxlength={10} maxlength={10}
placeholder="Máx. 10 car." placeholder="Máx. 10 car."
/> />
{#if fieldErrors.short_name}
<p class="text-xs text-destructive">{fieldErrors.short_name}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="rfc">RFC</Label> <Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} maxlength={30} /> <Input
id="rfc"
bind:value={formData.rfc}
aria-invalid={!!fieldErrors.rfc}
oninput={() => clearFieldError('rfc')}
maxlength={30}
/>
{#if fieldErrors.rfc}
<p class="text-xs text-destructive">{fieldErrors.rfc}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="responsible">Responsable</Label> <Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} maxlength={100} /> <Input
id="responsible"
bind:value={formData.responsible}
aria-invalid={!!fieldErrors.responsible}
oninput={() => clearFieldError('responsible')}
maxlength={100}
/>
{#if fieldErrors.responsible}
<p class="text-xs text-destructive">{fieldErrors.responsible}</p>
{/if}
</div> </div>
</section> </section>
@@ -251,7 +373,16 @@
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label> <Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} maxlength={49} /> <Input
id="caat_code"
bind:value={formData.caat_code}
aria-invalid={!!fieldErrors.caat_code}
oninput={() => clearFieldError('caat_code')}
maxlength={49}
/>
{#if fieldErrors.caat_code}
<p class="text-xs text-destructive">{fieldErrors.caat_code}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -259,9 +390,14 @@
<Input <Input
id="transport_code" id="transport_code"
bind:value={formData.transport_code} bind:value={formData.transport_code}
aria-invalid={!!fieldErrors.transport_code}
oninput={() => clearFieldError('transport_code')}
maxlength={8} maxlength={8}
placeholder="Máx. 8 car." placeholder="Máx. 8 car."
/> />
{#if fieldErrors.transport_code}
<p class="text-xs text-destructive">{fieldErrors.transport_code}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -269,9 +405,14 @@
<Input <Input
id="loader_code" id="loader_code"
bind:value={formData.loader_code} bind:value={formData.loader_code}
aria-invalid={!!fieldErrors.loader_code}
oninput={() => clearFieldError('loader_code')}
maxlength={9} maxlength={9}
placeholder="Máx. 9 car." placeholder="Máx. 9 car."
/> />
{#if fieldErrors.loader_code}
<p class="text-xs text-destructive">{fieldErrors.loader_code}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
@@ -279,13 +420,27 @@
<Input <Input
id="transport_interface_type" id="transport_interface_type"
bind:value={formData.transport_interface_type} bind:value={formData.transport_interface_type}
aria-invalid={!!fieldErrors.transport_interface_type}
oninput={() => clearFieldError('transport_interface_type')}
maxlength={20} maxlength={20}
/> />
{#if fieldErrors.transport_interface_type}
<p class="text-xs text-destructive">{fieldErrors.transport_interface_type}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="filler_code">Código Relleno</Label> <Label for="filler_code">Código Relleno</Label>
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} /> <Input
id="filler_code"
bind:value={formData.filler_code}
aria-invalid={!!fieldErrors.filler_code}
oninput={() => clearFieldError('filler_code')}
maxlength={20}
/>
{#if fieldErrors.filler_code}
<p class="text-xs text-destructive">{fieldErrors.filler_code}</p>
{/if}
</div> </div>
<div class="flex items-center justify-between gap-3 rounded-lg border p-4"> <div class="flex items-center justify-between gap-3 rounded-lg border p-4">
@@ -300,18 +455,44 @@
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="streets">Calle y Número</Label> <Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} maxlength={100} /> <Input
id="streets"
bind:value={formData.streets}
aria-invalid={!!fieldErrors.streets}
oninput={() => clearFieldError('streets')}
maxlength={100}
/>
{#if fieldErrors.streets}
<p class="text-xs text-destructive">{fieldErrors.streets}</p>
{/if}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="city">Ciudad</Label> <Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} maxlength={30} /> <Input
id="city"
bind:value={formData.city}
aria-invalid={!!fieldErrors.city}
oninput={() => clearFieldError('city')}
maxlength={30}
/>
{#if fieldErrors.city}
<p class="text-xs text-destructive">{fieldErrors.city}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="country">País (clave americana)</Label> <Label for="country">País (clave americana)</Label>
<Select.Root type="single" bind:value={formData.country} disabled={refsLoading}> <Select.Root
<Select.Trigger class="w-full" id="country"> type="single"
bind:value={formData.country}
disabled={refsLoading}
onValueChange={() => clearFieldError('country')}
>
<Select.Trigger
class={fieldErrors.country ? 'border-destructive' : ''}
id="country"
>
{refsLoading {refsLoading
? '...' ? '...'
: formData.country : formData.country
@@ -327,14 +508,25 @@
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
{#if fieldErrors.country}
<p class="text-xs text-destructive">{fieldErrors.country}</p>
{/if}
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="state">Estado / provincia</Label> <Label for="state">Estado / provincia</Label>
<Select.Root type="single" bind:value={formData.state} disabled={refsLoading}> <Select.Root
<Select.Trigger class="w-full" id="state"> type="single"
bind:value={formData.state}
disabled={refsLoading}
onValueChange={() => clearFieldError('state')}
>
<Select.Trigger
class={fieldErrors.state ? 'border-destructive' : ''}
id="state"
>
{refsLoading {refsLoading
? '...' ? '...'
: formData.state || : formData.state ||
@@ -349,10 +541,22 @@
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
{#if fieldErrors.state}
<p class="text-xs text-destructive">{fieldErrors.state}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="postal_code">C.P.</Label> <Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} maxlength={15} /> <Input
id="postal_code"
bind:value={formData.postal_code}
aria-invalid={!!fieldErrors.postal_code}
oninput={() => clearFieldError('postal_code')}
maxlength={15}
/>
{#if fieldErrors.postal_code}
<p class="text-xs text-destructive">{fieldErrors.postal_code}</p>
{/if}
</div> </div>
</div> </div>
</section> </section>
@@ -363,23 +567,60 @@
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label> <Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} maxlength={200} /> <Input
id="ftp_server"
bind:value={formData.ftp_server}
aria-invalid={!!fieldErrors.ftp_server}
oninput={() => clearFieldError('ftp_server')}
maxlength={200}
/>
{#if fieldErrors.ftp_server}
<p class="text-xs text-destructive">{fieldErrors.ftp_server}</p>
{/if}
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ftp_user">Usuario</Label> <Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} maxlength={200} /> <Input
id="ftp_user"
bind:value={formData.ftp_user}
aria-invalid={!!fieldErrors.ftp_user}
oninput={() => clearFieldError('ftp_user')}
maxlength={200}
/>
{#if fieldErrors.ftp_user}
<p class="text-xs text-destructive">{fieldErrors.ftp_user}</p>
{/if}
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label> <Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} maxlength={100} /> <Input
id="ftp_password"
type="password"
bind:value={formData.ftp_password}
aria-invalid={!!fieldErrors.ftp_password}
oninput={() => clearFieldError('ftp_password')}
maxlength={100}
/>
{#if fieldErrors.ftp_password}
<p class="text-xs text-destructive">{fieldErrors.ftp_password}</p>
{/if}
</div> </div>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label> <Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} maxlength={1000} /> <Input
id="ftp_directory"
bind:value={formData.ftp_directory}
aria-invalid={!!fieldErrors.ftp_directory}
oninput={() => clearFieldError('ftp_directory')}
maxlength={1000}
/>
{#if fieldErrors.ftp_directory}
<p class="text-xs text-destructive">{fieldErrors.ftp_directory}</p>
{/if}
</div> </div>
</section> </section>
</div> </div>

View File

@@ -215,7 +215,7 @@
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-h-[95vh] max-w-3xl overflow-y-auto"> <Dialog.Content class="max-h-[95vh] max-w-3xl overflow-y-auto" onInteractOutside={(e) => e.preventDefault()}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{title}</Dialog.Title> <Dialog.Title>{title}</Dialog.Title>
<Dialog.Description> <Dialog.Description>