Merge branch 'development' into feature/catalog-drivers

This commit is contained in:
hreyes
2026-03-06 10:14:55 -07:00
11 changed files with 766 additions and 224 deletions

View File

@@ -12,16 +12,16 @@ class CustomsBrokerBaseDTO(BaseModel):
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
phone: Optional[str] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
@@ -58,16 +58,16 @@ class CustomsBrokerDTO(BaseModel):
postal_code: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
phone: Optional[str] = Field(None, pattern=r"^$|^[\d\s\-\+\(\)]+$")
fax: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
country: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
company: Optional[str] = None
contact: Optional[str] = None
contact: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]+$")
tenant_id: str
company_id: str
@@ -109,14 +109,14 @@ class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
line: int
name: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
tax_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$")
personal_id: Optional[str] = Field(None, pattern=r"^$|^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$")
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
license: Optional[str] = Field(None, max_length=4, pattern=r"^$|^[0-9]*[1-9][0-9]*$")
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
email: Optional[str] = None
email: Optional[str] = Field(None, pattern=r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
tenant_id: Optional[int] = None
company_id: Optional[int] = None

View File

@@ -1,4 +1,5 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from . import dto, models
@@ -45,9 +46,13 @@ class CustomsBrokerService:
new_broker = models.CustomsBroker(**broker_dict)
db.add(new_broker)
db.commit()
db.refresh(new_broker)
return new_broker
try:
db.commit()
db.refresh(new_broker)
return new_broker
except IntegrityError:
db.rollback()
raise ValueError("La clave del agente ya existe o hay datos duplicados.")
@staticmethod
def update(db: Session, broker_key: str, tenant_id: int, broker_data: dto.CustomsBrokerUpdateDTO, company_id: int):
@@ -56,8 +61,12 @@ class CustomsBrokerService:
if broker:
for key, value in broker_data.model_dump(exclude_unset=True).items():
setattr(broker, key, value)
db.commit()
db.refresh(broker)
try:
db.commit()
db.refresh(broker)
except IntegrityError:
db.rollback()
raise ValueError("Los datos duplicados no pueden ser guardados o hay un conflicto de integridad.")
return broker
@staticmethod

View File

@@ -8,7 +8,9 @@ from typing import Any, Dict
from fastapi import Request, status, HTTPException
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from .config import settings
@@ -57,20 +59,69 @@ async def base_exception_handler(
return response
# Mapa de campos técnicos a nombres legibles en español
_FIELD_LABELS: Dict[str, str] = {
"broker_key": "Clave del Agente",
"license": "Patente",
"tax_id": "RFC",
"personal_id": "CURP",
"email": "Correo Electrónico",
"phone": "Teléfono",
"fax": "Fax",
"contact": "Nombre de Contacto",
"name": "Nombre / Razón Social",
"address": "Dirección",
"postal_code": "Código Postal",
"city": "Ciudad",
"state": "Estado",
"country": "País",
}
_FIELD_PATTERN_MESSAGES: Dict[str, str] = {
"broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).",
"license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).",
"tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.",
"personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.",
"email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.",
"phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).",
"contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.",
}
def _friendly_message(field_key: str, error_type: str) -> str:
"""Devuelve un mensaje de error legible en español según el campo y tipo de error."""
if error_type in ("string_pattern_mismatch", "value_error"):
return _FIELD_PATTERN_MESSAGES.get(
field_key,
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.",
)
if error_type == "string_too_long":
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida."
if error_type == "string_too_short":
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto."
if error_type in ("missing", "value_error.missing"):
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio."
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido."
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""
Manejador para errores de validación de Pydantic/FastAPI
Manejador para errores de validación de Pydantic/FastAPI.
Devuelve mensajes legibles en español.
"""
errors = []
for error in exc.errors():
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
field = ".".join(loc_parts)
field_key = loc_parts[-1] if loc_parts else ""
errors.append(
{
"field": field,
"message": error["msg"],
"message": _friendly_message(field_key, error["type"]),
"type": error["type"],
}
)
@@ -80,11 +131,63 @@ async def validation_exception_handler(
extra={"errors": errors},
)
summary = (
errors[0]["message"]
if len(errors) == 1
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
)
response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
"message": "Error de validación en los datos recibidos",
"message": summary,
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"errors": errors,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def inner_validation_exception_handler(
request: Request,
exc: ValidationError,
) -> JSONResponse:
"""
Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes).
"""
errors = []
for error in exc.errors():
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
field = ".".join(loc_parts)
field_key = loc_parts[-1] if loc_parts else ""
errors.append(
{
"field": field,
"message": _friendly_message(field_key, error["type"]),
"type": error["type"],
}
)
logger.warning(
f"Inner Validation Error en {request.url.path}",
extra={"errors": errors},
)
summary = (
errors[0]["message"]
if len(errors) == 1
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
)
response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
"message": summary,
"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"errors": errors,
},
@@ -221,6 +324,7 @@ def register_exception_handlers(app) -> None:
app.add_exception_handler(BaseAPIException, base_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(ValidationError, inner_validation_exception_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
app.add_exception_handler(Exception, general_exception_handler)

View File

@@ -154,36 +154,11 @@ def _cors_headers_for_request(request: Request):
return {}
# Add validation error handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(
f"Validation error for {request.method} {request.url.path}: {exc.errors()}"
)
logger.error(f"Request body: {await request.body()}")
response = JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.errors(), "body": exc.body},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
# Add HTTP exception handler
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(
f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}"
)
response = JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
def run_migrations():
subprocess.run(["alembic", "upgrade", "head"], check=True)

View File

@@ -38,6 +38,7 @@
city: '',
state: '',
country: '',
personal_id: '',
tenant_id: '', // Se llenará en el submit o por defecto
company_id: ''
};
@@ -62,6 +63,7 @@
contact: initialData.contact || '',
address: initialData.address || '',
postal_code: initialData.postal_code || '',
personal_id: initialData.personal_id || '',
city: initialData.city || '',
state: initialData.state || '',
country: initialData.country || '',
@@ -80,6 +82,7 @@
contact: '',
address: '',
postal_code: '',
personal_id: '',
city: '',
state: '',
country: 'MEX', // Valor por defecto sugerido
@@ -116,6 +119,48 @@
loading = false;
return;
}
if (
formData.license === '0' ||
formData.license === '0000' ||
/^0+$/.test(formData.license)
) {
toast.error('La Patente no puede ser 0');
loading = false;
return;
}
if (formData.tax_id && !/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i.test(formData.tax_id)) {
toast.error('El formato del RFC es inválido');
loading = false;
return;
}
if (
formData.personal_id &&
!/^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$/i.test(
formData.personal_id
)
) {
toast.error('El formato de la CURP es inválido');
loading = false;
return;
}
if (
formData.email &&
!/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(formData.email)
) {
toast.error('El formato del correo electrónico es inválido');
loading = false;
return;
}
if (formData.phone && !/^[\d\s\-\+\(\)]+$/.test(formData.phone)) {
toast.error('El teléfono contiene caracteres no válidos');
loading = false;
return;
}
if (formData.fax && !/^[\d\s\-\+\(\)]+$/.test(formData.fax)) {
toast.error('El fax contiene caracteres no válidos');
loading = false;
return;
}
// Inyectar company_id si no viene
const payload = { ...formData, company_id: companyId };
@@ -125,9 +170,14 @@
toast.success(
mode === 'create' ? 'Agente creado correctamente' : 'Agente actualizado correctamente'
);
} catch (error) {
} catch (error: unknown) {
console.error(error);
toast.error('Error al guardar el agente aduanal');
// Intentar extraer el mensaje del servidor si lo hay
let msg = 'Error al guardar el agente aduanal';
if (error && typeof error === 'object' && 'message' in error) {
msg = (error as { message: string }).message || msg;
}
toast.error(msg);
} finally {
loading = false;
}
@@ -135,7 +185,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>
{mode === 'create' ? 'Nuevo Agente Aduanal' : 'Editar Agente Aduanal'}
@@ -148,7 +198,7 @@
<div class="grid gap-6 py-4">
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Identificación</h4>
<h4 class="text-sm leading-none font-medium text-muted-foreground">Identificación</h4>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="broker_key">Clave Agente *</Label>
@@ -156,7 +206,7 @@
id="broker_key"
value={formData.broker_key}
oninput={(e) => {
const val = e.currentTarget.value.toUpperCase();
const val = e.currentTarget.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (val.length > 5) {
brokerKeyError = true;
formData.broker_key = val.slice(0, 5);
@@ -169,10 +219,11 @@
} else {
brokerKeyError = false;
formData.broker_key = val;
e.currentTarget.value = formData.broker_key;
}
}}
placeholder="Ej. 550"
maxlength="6"
maxlength={6}
class={brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={mode === 'edit' || loading}
/>
@@ -204,7 +255,7 @@
}
}}
placeholder="Ej. 3421"
maxlength="5"
maxlength={5}
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={loading}
/>
@@ -216,7 +267,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2 col-span-2">
<div class="col-span-2 space-y-2">
<Label for="name">Nombre / Razón Social</Label>
<Input
id="name"
@@ -229,16 +280,48 @@
<Label for="tax_id">RFC</Label>
<Input
id="tax_id"
bind:value={formData.tax_id}
value={formData.tax_id}
oninput={(e) => {
formData.tax_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9&Ñ]/g, '')
.slice(0, 13);
e.currentTarget.value = formData.tax_id;
}}
placeholder="RFC de la agencia"
maxlength={13}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="personal_id">CURP</Label>
<Input
id="personal_id"
value={formData.personal_id}
oninput={(e) => {
formData.personal_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.slice(0, 18);
e.currentTarget.value = formData.personal_id;
}}
placeholder="CURP (Opcional)"
maxlength={18}
disabled={loading}
/>
</div>
<div class="col-span-2 space-y-2">
<Label for="contact">Nombre Contacto</Label>
<Input
id="contact"
bind:value={formData.contact}
value={formData.contact}
oninput={(e) => {
formData.contact = e.currentTarget.value.replace(
/[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g,
''
);
e.currentTarget.value = formData.contact;
}}
placeholder="Persona de contacto"
disabled={loading}
/>
@@ -249,13 +332,33 @@
<Separator />
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Contacto</h4>
<h4 class="text-sm leading-none font-medium text-muted-foreground">Contacto</h4>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2 col-span-1">
<div class="col-span-1 space-y-2">
<Label for="phone">Teléfono</Label>
<Input id="phone" bind:value={formData.phone} disabled={loading} />
<Input
id="phone"
value={formData.phone}
oninput={(e) => {
formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.phone;
}}
disabled={loading}
/>
</div>
<div class="space-y-2 col-span-2">
<div class="col-span-1 space-y-2">
<Label for="fax">Fax</Label>
<Input
id="fax"
value={formData.fax}
oninput={(e) => {
formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.fax;
}}
disabled={loading}
/>
</div>
<div class="col-span-1 space-y-2">
<Label for="email">Correo Electrónico</Label>
<Input id="email" type="email" bind:value={formData.email} disabled={loading} />
</div>
@@ -265,7 +368,7 @@
<Separator />
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Dirección Fiscal</h4>
<h4 class="text-sm leading-none font-medium text-muted-foreground">Dirección Fiscal</h4>
<div class="space-y-2">
<Label for="address">Calle y Número</Label>
@@ -275,9 +378,16 @@
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }} />
<Input
id="postal_code"
bind:value={formData.postal_code}
disabled={loading}
oninput={(e) => {
formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, '');
}}
/>
</div>
<div class="space-y-2 col-span-2">
<div class="col-span-2 space-y-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} disabled={loading} />
</div>

View File

@@ -79,6 +79,41 @@
loading = true;
error = null;
// Validaciones Básicas
if (formData.license === '0' || formData.license === '0000' || /^0+$/.test(formData.license)) {
error = 'La Patente no puede ser 0';
loading = false;
return;
}
if (formData.tax_id && !/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i.test(formData.tax_id)) {
error = 'El formato del RFC es inválido';
loading = false;
return;
}
if (
formData.personal_id &&
!/^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$/i.test(
formData.personal_id
)
) {
error = 'El formato de la CURP es inválido';
loading = false;
return;
}
if (
formData.email &&
!/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(formData.email)
) {
error = 'El formato del correo electrónico es inválido';
loading = false;
return;
}
if (formData.phone && !/^[\d\s\-\+\(\)]+$/.test(formData.phone)) {
error = 'El teléfono contiene caracteres no válidos';
loading = false;
return;
}
try {
const payload: CreateCustomsBrokerData = {
broker_key: broker.broker_key, // La clave no se edita
@@ -138,7 +173,7 @@
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>Editar Agente Aduanal</Dialog.Title>
<Dialog.Description>
@@ -218,7 +253,7 @@
}
}}
placeholder="Número de patente"
maxlength="5"
maxlength={5}
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={loading}
/>
@@ -251,7 +286,11 @@
<Label for="edit-phone">Teléfono</Label>
<Input
id="edit-phone"
bind:value={formData.phone}
value={formData.phone}
oninput={(e) => {
formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.phone;
}}
placeholder="Número telefónico"
maxlength={30}
disabled={loading}
@@ -262,7 +301,11 @@
<Label for="edit-fax">Fax</Label>
<Input
id="edit-fax"
bind:value={formData.fax}
value={formData.fax}
oninput={(e) => {
formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.fax;
}}
placeholder="Número de fax"
maxlength={30}
disabled={loading}
@@ -286,7 +329,14 @@
<Label for="edit-contact">Contacto</Label>
<Input
id="edit-contact"
bind:value={formData.contact}
value={formData.contact}
oninput={(e) => {
formData.contact = e.currentTarget.value.replace(
/[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g,
''
);
e.currentTarget.value = formData.contact;
}}
placeholder="Nombre del contacto"
maxlength={80}
disabled={loading}
@@ -318,7 +368,9 @@
placeholder="C.P."
maxlength={15}
disabled={loading}
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
oninput={(e) => {
formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, '');
}}
/>
</div>
@@ -366,9 +418,16 @@
<Label for="edit-tax_id">RFC</Label>
<Input
id="edit-tax_id"
bind:value={formData.tax_id}
value={formData.tax_id}
oninput={(e) => {
formData.tax_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9&Ñ]/g, '')
.slice(0, 13);
e.currentTarget.value = formData.tax_id;
}}
placeholder="RFC"
maxlength={30}
maxlength={13}
disabled={loading}
/>
</div>
@@ -377,9 +436,16 @@
<Label for="edit-personal_id">CURP</Label>
<Input
id="edit-personal_id"
bind:value={formData.personal_id}
value={formData.personal_id}
oninput={(e) => {
formData.personal_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.slice(0, 18);
e.currentTarget.value = formData.personal_id;
}}
placeholder="CURP"
maxlength={20}
maxlength={18}
disabled={loading}
/>
</div>

View File

@@ -1,7 +1,6 @@
import type { ColumnDef } from "@tanstack/table-core";
import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js";
import { renderSnippet } from "$lib/components/ui/data-table/index.js";
import { createRawSnippet } from "svelte";
import DataTableActions from "./data-table-actions.svelte";
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { getInvoiceTypeColor } from "$lib/utils";
@@ -18,29 +17,71 @@ export function createColumns(
onSuccess?: () => void
): ColumnDef<Invoice>[] {
return [
// 0. NUEVA COLUMNA: Checkbox visual (el estado real lo maneja la opacidad)
// Checkbox visual
{
id: "select",
header: ({ table }) => {
return renderSnippet(
createRawSnippet(() => ({
render: () => `<div class="w-4"></div>`
}))
);
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
const selectAllSnippet = createRawSnippet<[
{ checked: boolean; indeterminate: boolean; onchange: (e: Event) => void }
]>((getProps) => {
const { checked, indeterminate, onchange } = getProps();
return {
render: () => `<div class="w-4">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
${checked ? 'checked' : ''}
${indeterminate ? 'indeterminate="true"' : ''}
/>
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement;
if (input) {
input.indeterminate = indeterminate;
input.addEventListener('change', onchange);
}
}
};
});
return renderSnippet(selectAllSnippet, {
checked: isAllSelected,
indeterminate: isSomeSelected && !isAllSelected,
onchange: (e: Event) => {
table.toggleAllPageRowsSelected(!!(e.target as HTMLInputElement).checked);
}
});
},
cell: ({ row }) => {
const isSelected = row.getIsSelected();
const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => {
const { selected } = getProps();
const checkboxSnippet = createRawSnippet<[
{ selected: boolean; onchange: (e: Event) => void }
]>((getProps) => {
const { selected, onchange } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" class="h-4 w-4" ${selected ? 'checked' : ''} />
</div>`
<input type="checkbox" class="h-4 w-4 cursor-pointer" ${selected ? 'checked' : ''} />
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement;
if (input) {
input.addEventListener('change', onchange);
}
}
};
});
return renderSnippet(checkboxSnippet, { selected: isSelected });
return renderSnippet(checkboxSnippet, {
selected: isSelected,
onchange: (e: Event) => {
e.stopPropagation(); // Evitar que el clic en el checkbox dispare el rowClick
row.toggleSelected(!!(e.target as HTMLInputElement).checked);
}
});
},
enableSorting: false,
enableHiding: false,
@@ -298,16 +339,6 @@ export function createColumns(
return renderSnippet(relDocSnippet, { relDoc });
}
},
// 2. MODIFICAMOS AQUÍ: Pasamos onDownload al componente
{
id: "actions",
cell: ({ row }) => {
return renderComponent(DataTableActions, {
invoice: row.original,
onSuccess
});
}
}
];
}

View File

@@ -1,11 +1,8 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -14,8 +11,9 @@
hasMore: boolean;
loadMore: () => void;
// Props para selección
selectedId?: number | null;
selectedIds?: number[];
onRowClick?: (row: TData) => void;
compact?: boolean;
};
let {
@@ -24,8 +22,9 @@
loading,
hasMore,
loadMore,
selectedId = null,
onRowClick
selectedIds = [],
onRowClick,
compact = false
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -37,12 +36,17 @@
getRowId: (row: any) => row.id?.toString(), // Usar ID para identificar filas
state: {
get rowSelection() {
// Mapear el ID seleccionado al formato que espera TanStack Table
return selectedId ? { [selectedId]: true } : {};
// Mapear los IDs seleccionados al formato que espera TanStack Table
const selection: Record<string, boolean> = {};
selectedIds.forEach((id) => {
selection[id.toString()] = true;
});
return selection;
}
},
enableRowSelection: true,
enableMultiRowSelection: false, // Solo permitir una selección a la vez
enableMultiRowSelection: true
// No necesitamos onRowSelectionChange porque controlamos el estado desde fuera
});
@@ -75,7 +79,7 @@
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<div class="max-h-[600px] overflow-y-auto rounded-md border" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="bg-background">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
@@ -95,17 +99,16 @@
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
data-state={row.getIsSelected() && "selected"}
class="cursor-pointer transition-colors {row.getIsSelected() ? 'bg-gray-300 dark:bg-gray-600' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
<Table.Row
data-state={row.getIsSelected() && 'selected'}
class="cursor-pointer transition-colors {row.getIsSelected()
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => onRowClick && onRowClick(row.original)}
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
@@ -116,7 +119,7 @@
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
@@ -124,13 +127,13 @@
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"
></div>
<span class="text-sm text-muted-foreground">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
<div class="text-sm text-muted-foreground">Desplázate para cargar más</div>
{/if}
</div>
</Table.Cell>

View File

@@ -1,44 +1,63 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { invoicesApi, type Invoice } from "$lib/api/dashboard/a76/invoices";
import { companyStore } from "$lib/stores/company.svelte";
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
import { companyStore } from '$lib/stores/company.svelte';
import { LoaderCircle } from 'lucide-svelte';
interface Props {
invoice: Invoice;
invoice?: Invoice | null;
invoicesToDelete?: Invoice[];
onClose: () => void;
onSuccess?: () => void;
}
let { invoice, onClose, onSuccess }: Props = $props();
let { invoice = null, invoicesToDelete = [], onClose, onSuccess }: Props = $props();
let open = $state(true);
let isMultiDelete = $derived(invoicesToDelete.length > 1);
let isSingleDelete = $derived(invoicesToDelete.length <= 1 && invoice !== null);
let loading = $state(false);
let error = $state<string | null>(null);
async function handleDelete() {
if (!invoice || !companyStore.activeCompany) return;
const itemsToDelete = invoicesToDelete.length > 0 ? invoicesToDelete : invoice ? [invoice] : [];
if (itemsToDelete.length === 0 || !companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await invoicesApi.delete(invoice.id, companyStore.activeCompany.id);
let successCount = 0;
let failCount = 0;
if (response.error) {
error = response.error;
return;
try {
for (const item of itemsToDelete) {
const response = await invoicesApi.delete(item.id, companyStore.activeCompany.id);
if (response.error) {
failCount++;
console.error(`Error deleting invoice ${item.id}:`, response.error);
} else {
successCount++;
}
}
// Éxito
onClose();
if (onSuccess) {
onSuccess();
if (failCount > 0) {
error = `Se eliminaron ${successCount} facturas, pero fallaron ${failCount}. Revisa la consola para más detalles.`;
if (successCount === 0) return; // Si todas fallaron, no cerramos
}
// Si al menos una tuvo éxito (o todas), refrescamos
if (successCount > 0) {
onClose();
if (onSuccess) {
onSuccess();
}
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al eliminar";
console.error("Error deleting:", e);
error = e instanceof Error ? e.message : 'Error al eliminar';
console.error('Error deleting:', e);
} finally {
loading = false;
}
@@ -59,9 +78,17 @@
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description class="space-y-2">
<p>Esta acción no se puede deshacer. Se eliminará permanentemente esta factura:</p>
{#if invoice}
<div class="mt-2 rounded-lg bg-muted p-3 space-y-2">
{#if isMultiDelete}
<p>
Esta acción no se puede deshacer. Se eliminarán permanentemente <strong
>{invoicesToDelete.length}</strong
> facturas seleccionadas.
</p>
{:else}
<p>Esta acción no se puede deshacer. Se eliminará permanentemente esta factura:</p>
{/if}
{#if isSingleDelete && invoice}
<div class="mt-2 space-y-2 rounded-lg bg-muted p-3">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">ID:</span>
<span class="font-semibold">{invoice.id}</span>
@@ -73,8 +100,11 @@
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Tipo:</span>
<span class="text-xs">
{invoice.operation_type === 'imp' ? 'Importación' :
invoice.operation_type === 'exp' ? 'Exportación' : 'N/A'}
{invoice.operation_type === 'imp'
? 'Importación'
: invoice.operation_type === 'exp'
? 'Exportación'
: 'N/A'}
</span>
</div>
<div class="flex items-center justify-between text-sm">
@@ -88,7 +118,9 @@
</div>
{/if}
{#if error}
<div class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
<div
class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
{/if}
@@ -99,7 +131,7 @@
<AlertDialog.Action
onclick={handleDelete}
disabled={loading}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />

View File

@@ -228,6 +228,44 @@
return;
}
if (formData.license === '0' || formData.license === '0000' || /^0+$/.test(formData.license)) {
error = 'La Patente no puede ser 0';
toast.error(error);
return;
}
if (formData.tax_id && !/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/i.test(formData.tax_id)) {
error = 'El formato del RFC es inválido';
toast.error(error);
return;
}
if (
formData.personal_id &&
!/^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM](AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$/i.test(
formData.personal_id
)
) {
error = 'El formato de la CURP es inválido';
toast.error(error);
return;
}
if (
formData.email &&
!/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(formData.email)
) {
error = 'El formato del correo electrónico es inválido';
toast.error(error);
return;
}
if (formData.phone && !/^[\d\s\-\+\(\)]+$/.test(formData.phone)) {
error = 'El teléfono contiene caracteres no válidos';
toast.error(error);
return;
}
loading = true;
error = null;
try {
@@ -435,7 +473,14 @@
<div class="grid gap-2">
<Label>RFC</Label>
<Input
bind:value={formData.tax_id}
value={formData.tax_id}
oninput={(e) => {
formData.tax_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9&Ñ]/g, '')
.slice(0, 13);
e.currentTarget.value = formData.tax_id;
}}
placeholder="RFC de la empresa"
disabled={loading}
class="h-10"
@@ -444,7 +489,14 @@
<div class="grid gap-2">
<Label>CURP</Label>
<Input
bind:value={formData.personal_id}
value={formData.personal_id}
oninput={(e) => {
formData.personal_id = e.currentTarget.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.slice(0, 18);
e.currentTarget.value = formData.personal_id;
}}
placeholder="CURP si aplica"
disabled={loading}
class="h-10"
@@ -467,7 +519,14 @@
<div class="grid gap-2">
<Label>Persona de Contacto</Label>
<Input
bind:value={formData.contact}
value={formData.contact}
oninput={(e) => {
formData.contact = e.currentTarget.value.replace(
/[^a-zA-Z0-9\sñÑáéíóúÁÉÍÓÚ\-\.,]/g,
''
);
e.currentTarget.value = formData.contact;
}}
placeholder="Nombre del contacto"
disabled={loading}
class="h-10"
@@ -490,7 +549,11 @@
<div class="grid gap-2">
<Label>Teléfono</Label>
<Input
bind:value={formData.phone}
value={formData.phone}
oninput={(e) => {
formData.phone = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.phone;
}}
placeholder="656-000-0000"
disabled={loading}
class="h-10"
@@ -498,7 +561,15 @@
</div>
<div class="grid gap-2">
<Label>Fax</Label>
<Input bind:value={formData.fax} disabled={loading} class="h-10" />
<Input
value={formData.fax}
oninput={(e) => {
formData.fax = e.currentTarget.value.replace(/[^\d\s\-\+\(\)]/g, '');
e.currentTarget.value = formData.fax;
}}
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label>Correo Electrónico</Label>
@@ -540,7 +611,9 @@
placeholder="32000"
disabled={loading}
class="h-10"
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
oninput={(e) => {
formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, '');
}}
/>
</div>
<div class="grid gap-2 md:col-span-2">

View File

@@ -30,11 +30,26 @@
Package,
ClipboardList,
Settings,
Send
Send,
Eye,
Pencil,
Trash2,
Download,
MonitorUp,
Files,
ScrollText,
BadgeCent,
ArrowRightLeft,
Database,
ChevronUp
} from 'lucide-svelte';
import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte';
import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
import { toast } from 'svelte-sonner';
import { fly } from 'svelte/transition';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaFacturas } from '$lib/config/shortcuts/dashboard/invoices/list';
@@ -183,23 +198,29 @@
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
// Estado para selección de fila
let selectedInvoiceId = $state<number | null>(null);
// Estado para selección de filas (múltiple)
let selectedInvoiceIds = $state<number[]>([]);
// Estado para los diálogos de acciones
let showDetailsDialog = $state(false);
let showDeleteDialog = $state(false);
function handleRowClick(invoice: Invoice) {
// Si ya está seleccionado, lo deseleccionamos (opcional, si queremos permitir toggle)
// O simplemente lo seleccionamos. Aquí implemento toggle.
if (selectedInvoiceId === invoice.id) {
selectedInvoiceId = null;
const id = invoice.id;
if (selectedInvoiceIds.includes(id)) {
// Deseleccionar si ya estaba
selectedInvoiceIds = selectedInvoiceIds.filter((selectedId) => selectedId !== id);
} else {
selectedInvoiceId = invoice.id;
// Seleccionar agregando a la lista
selectedInvoiceIds = [...selectedInvoiceIds, id];
}
}
const selectedInvoice = $derived(
selectedInvoiceId ? allItems.find((i) => i.id === selectedInvoiceId) : null
selectedInvoiceIds.length === 1 ? allItems.find((i) => i.id === selectedInvoiceIds[0]) : null
);
const hasSelection = $derived(selectedInvoiceIds.length > 0);
async function loadMore() {
if (loading || !hasMore) return;
@@ -829,10 +850,12 @@
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" onclick={reloadData}>
<RefreshCw class="mr-2" size={16} />
Actualizar
</Button>
<div class="flex items-center gap-2">
<Button variant="outline" onclick={reloadData}>
<RefreshCw class="mr-2" size={16} />
Actualizar
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
@@ -842,7 +865,7 @@
{loading}
{hasMore}
{loadMore}
selectedId={selectedInvoiceId}
selectedIds={selectedInvoiceIds}
onRowClick={handleRowClick}
/>
</Card.Content>
@@ -880,11 +903,138 @@
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<div class="flex w-full items-center justify-end gap-2">
{#if hasSelection}
<div transition:fly={{ x: 40, duration: 250 }} class="flex items-center gap-2">
<!-- Dropdown: Reportes -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="sm"
disabled={selectedInvoiceIds.length !== 1}
>
Reportes
<ChevronUp class="ml-2 h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="max-h-[400px] w-56 overflow-y-auto">
<DropdownMenu.Group>
<DropdownMenu.Label>Descargas</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => (isDownloadModalOpen = true)}>
<FileText class="mr-2 h-4 w-4" />
Factura PDF
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => toast.info('Generar Factura CSV - Próximamente')}
>
<Download class="mr-2 h-4 w-4" />
Factura CSV
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => selectedInvoice && handleDownloadConsolidated(selectedInvoice)}
>
<Boxes class="mr-2 h-4 w-4" />
Consolidado
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
selectedInvoice && handleDownloadAvisoConsolidado(selectedInvoice)}
>
<Boxes class="mr-2 h-4 w-4" />
Aviso Consolidado
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => selectedInvoice && handleDownloadPackingList(selectedInvoice)}
>
<Package class="mr-2 h-4 w-4" />
Packing List
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('4 Copias Rem - Próximamente')}>
<Files class="mr-2 h-4 w-4" />
4 Copias Rem
</DropdownMenu.Item>
{#if selectedInvoice?.operation_type === 'exp'}
<DropdownMenu.Item onclick={() => handleDownloadDescargo(selectedInvoice)}>
<ClipboardList class="mr-2 h-4 w-4" />
Descargo PEPS
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dropdown: Más Acciones -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="sm"
disabled={selectedInvoiceIds.length !== 1}
>
Más Acciones
<ChevronUp class="ml-2 h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="max-h-[400px] w-56 overflow-y-auto">
<DropdownMenu.Group>
<DropdownMenu.Label>Otras Acciones</DropdownMenu.Label>
<DropdownMenu.Item
onclick={() => (isTransferenciaModalOpen = true)}
disabled={!companyStore.activeCompany}
>
<Send class="mr-2 h-4 w-4" />
Transferencia Electrónica
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('Interface VU - Próximamente')}>
<MonitorUp class="mr-2 h-4 w-4" />
Interface VU
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('Cons SED - Próximamente')}>
<ScrollText class="mr-2 h-4 w-4" />
Cons SED
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('Encomienda - Próximamente')}>
<BadgeCent class="mr-2 h-4 w-4" />
Encomienda
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => toast.info('Factura Mex Consolidada - Próximamente')}
>
<FileText class="mr-2 h-4 w-4" />
Fact Mex Cons
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => toast.info('Factura Mex Orden Captura - Próximamente')}
>
<FileText class="mr-2 h-4 w-4" />
Fact Mex Ord Cat
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('Export SIA - Próximamente')}>
<Database class="mr-2 h-4 w-4" />
Export SIA
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => toast.info('Interface - Próximamente')}>
<ArrowRightLeft class="mr-2 h-4 w-4" />
Interface
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
<div class="h-6 w-px bg-border"></div>
</div>
{/if}
<!-- Por ahora, Actualizar Estado funciona solo si hay ESTRICTAMENTE UNA seleccionada -->
<Button
variant="outline"
size="sm"
disabled={!selectedInvoice || loading}
disabled={loading || selectedInvoiceIds.length !== 1}
onclick={() => handleUpdateStatus(true)}
>
<RefreshCw class="mr-2 h-4 w-4" />
@@ -893,7 +1043,7 @@
<Button
variant="outline"
size="sm"
disabled={!selectedInvoice || loading}
disabled={loading || selectedInvoiceIds.length !== 1}
onclick={() => handleUpdateStatus(false)}
>
<RotateCcw class="mr-2 h-4 w-4" />
@@ -902,41 +1052,11 @@
<Button
variant="outline"
size="sm"
onclick={() => (isDownloadModalOpen = true)}
disabled={!selectedInvoice}
disabled={selectedInvoiceIds.length !== 1}
onclick={() => (showDetailsDialog = true)}
>
<FileText class="mr-2 h-4 w-4" />
Factura
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleDownloadConsolidated(selectedInvoice)}
disabled={!selectedInvoice}
>
<Boxes class="mr-2 h-4 w-4" />
Consolidado
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleDownloadAvisoConsolidado(selectedInvoice)}
disabled={!selectedInvoice}
>
<Boxes class="mr-2 h-4 w-4" />
Aviso Consolidado
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleDownloadPackingList(selectedInvoice)}
disabled={!selectedInvoice}
>
<Package class="mr-2 h-4 w-4" />
Packing List
<Eye class="mr-2 h-4 w-4" />
Ver Detalles
</Button>
<Button
@@ -952,24 +1072,21 @@
<Button
variant="outline"
size="sm"
onclick={() => (isTransferenciaModalOpen = true)}
disabled={!companyStore.activeCompany}
disabled={selectedInvoiceIds.length !== 1}
onclick={handleEditSelected}
>
<Send class="mr-2 h-4 w-4" />
Transferencia Electrónica
<Pencil class="mr-2 h-4 w-4" />
Editar
</Button>
<Button
variant="outline"
size="sm"
disabled={selectedInvoiceIds.length === 0}
onclick={() => (showDeleteDialog = true)}
>
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</Button>
{#if selectedInvoice?.operation_type === 'exp'}
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleDownloadDescargo(selectedInvoice)}
disabled={!selectedInvoice}
>
<ClipboardList class="mr-2 h-4 w-4" />
Descargo PEPS
</Button>
{/if}
</div>
</div>
</div>
@@ -980,4 +1097,26 @@
<InvoiceDownloadModal bind:open={isDownloadModalOpen} onConfirm={handleModalConfirm} />
<TransferenciaElectronicaModal bind:open={isTransferenciaModalOpen} invoice={selectedInvoice} />
{/if}
{#if showDetailsDialog && selectedInvoice}
<DetailsDialog invoice={selectedInvoice} onClose={() => (showDetailsDialog = false)} />
{/if}
{#if showDeleteDialog}
<DeleteDialog
invoice={selectedInvoice}
invoicesToDelete={selectedInvoiceIds
.map((id) => allItems.find((i) => i.id === id))
.filter((i): i is Invoice => i !== undefined)}
onClose={() => {
showDeleteDialog = false;
selectedInvoiceIds = [];
}}
onSuccess={() => {
showDeleteDialog = false;
selectedInvoiceIds = [];
handleSuccess();
}}
/>
{/if}
</div>