chore: rescate y restauracion de modulos base, a76 y digitalizacion tras conflictos

This commit is contained in:
2026-04-28 17:23:14 -05:00
parent bc433c7202
commit 8535fdf5cc
49 changed files with 4725 additions and 4040 deletions

View File

@@ -60,11 +60,17 @@ def list_expediente_archivos(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
search: str = Query(None),
status: str = Query(None),
rfc_consulta: str = Query(None),
e_document: str = Query(None),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return ExpedienteArchivoService.list(db, company_id, tenant_id, page, page_size, search)
return ExpedienteArchivoService.list(
db, company_id, tenant_id, page, page_size,
search=search, status=status, rfc_consulta=rfc_consulta, e_document=e_document
)
@router.get("/{record_id}", response_model=ExpedienteArchivoResponseDTO)

View File

@@ -75,6 +75,9 @@ class ExpedienteArchivoService:
page: int = 1,
page_size: int = 50,
search: Optional[str] = None,
status: Optional[str] = None,
rfc_consulta: Optional[str] = None,
e_document: Optional[str] = None,
) -> ExpedienteArchivoListResponse:
query = (
db.query(ExpedienteArchivo)
@@ -84,11 +87,22 @@ class ExpedienteArchivoService:
ExpedienteArchivo.deleted_at.is_(None),
)
)
if status:
query = query.filter(ExpedienteArchivo.status == status)
if rfc_consulta:
query = query.filter(ExpedienteArchivo.rfc_consulta.ilike(f"%{rfc_consulta}%"))
if e_document:
query = query.filter(ExpedienteArchivo.e_document.ilike(f"%{e_document}%"))
if search:
like = f"%{search}%"
query = query.filter(
ExpedienteArchivo.e_document.ilike(like)
| ExpedienteArchivo.tipo_documento.ilike(like)
or_(
ExpedienteArchivo.e_document.ilike(like),
ExpedienteArchivo.tipo_documento.ilike(like),
ExpedienteArchivo.rfc_consulta.ilike(like),
ExpedienteArchivo.num_operacion.ilike(like),
ExpedienteArchivo.nombre_archivo.ilike(like),
)
)
total = query.count()
items = query.order_by(ExpedienteArchivo.id.desc()).offset((page - 1) * page_size).limit(page_size).all()

View File

@@ -82,7 +82,7 @@ async def export_doda_list(
"""
Listado al estilo legacy: filtra `doda_date` (YYYYMMDD) entre inicio y fin.
"""
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
tenant_id = int(validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]))
try:
d0, d1, fmt, mode = parse_export_params(
date_from, date_to, file_format, date_mode
@@ -126,7 +126,7 @@ async def export_doda_pedimentos(
Reporte por DODA seleccionado: columnas alineadas al listado de pedimentos (PATENTE, DOCUMENTO, COVE, etc.).
Si no hay líneas, se devuelve el archivo solo con encabezados.
"""
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
tenant_id = int(validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]))
try:
fmt = parse_pedimento_export_format(file_format)
except ValueError as e:
@@ -168,6 +168,11 @@ _crud_router = TenantCRUDRoutes(
id_name="doda_id",
enable_list=True,
enable_filters=True,
list_permissions=["cat_doda.view"],
get_permissions=["cat_doda.view"],
create_permissions=["cat_doda.create"],
update_permissions=["cat_doda.edit"],
delete_permissions=["cat_doda.delete"],
).router
router.include_router(_crud_router)
@@ -183,7 +188,7 @@ async def get_doda_detail(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"])
doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
if not doda:
raise HTTPException(

View File

@@ -28,6 +28,11 @@ router = TenantCRUDRoutes(
resource_name="Electronic Notice",
enable_list=True,
enable_filters=True,
list_permissions=["cat_notices.view"],
get_permissions=["cat_notices.view"],
create_permissions=["cat_notices.create"],
update_permissions=["cat_notices.edit"],
delete_permissions=["cat_notices.delete"],
).router
@@ -43,7 +48,7 @@ async def get_notices_by_pedimento(
current_user: dict = Depends(get_current_user),
):
"""Get all electronic notices for a specific pedimento"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_notices.view"])
notices = ElectronicNoticeService.get_by_pedimento(
db, pedimento, tenant_id, company_id)
return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices]
@@ -61,7 +66,7 @@ async def get_notices_by_status(
current_user: dict = Depends(get_current_user),
):
"""Get all electronic notices with a specific status"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_notices.view"])
notices = ElectronicNoticeService.get_by_status(
db, status, tenant_id, company_id)
return [ElectronicNoticeResponseDTO.model_validate(notice) for notice in notices]

View File

@@ -16,7 +16,6 @@ router = APIRouter(prefix="/code-pedimento-regimens")
def list_code_pedimento_regimens(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
code: str = Query(None, description="Filter by code"),
regime: str = Query(None, description="Filter by regime"),
type: str = Query(None, description="Filter by type"),
@@ -24,8 +23,6 @@ def list_code_pedimento_regimens(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"])
skip = (page - 1) * page_size
query = db.query(CodePedimentoRegimen)

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
@@ -16,14 +16,10 @@ router = APIRouter(prefix="/containers")
async def list_containers(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
# 🛡️ Permiso de Lectura (Listado)
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.view"])
skip = (page - 1) * page_size
query = db.query(Container)
@@ -48,13 +44,9 @@ async def list_containers(
@router.get("/{key}", response_model=ContainerDTO)
async def get_container(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
# 🛡️ Permiso de Lectura (Individual)
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.view"])
obj = db.query(Container).filter(Container.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -64,13 +56,9 @@ async def get_container(
@router.post("/", response_model=ContainerDTO, status_code=201)
async def create_container(
data: ContainerDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
# 🛡️ Permiso de Creación
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.create"])
obj = Container(**data.dict())
db.add(obj)
db.commit()
@@ -82,13 +70,9 @@ async def create_container(
async def update_container(
key: str,
data: ContainerDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
# 🛡️ Permiso de Edición
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.edit"])
obj = db.query(Container).filter(Container.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -102,16 +86,12 @@ async def update_container(
@router.delete("/{key}", status_code=204)
async def delete_container(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
# 🛡️ Permiso de Borrado
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_containers.delete"])
obj = db.query(Container).filter(Container.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None
return None

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
@@ -15,14 +15,10 @@ router = APIRouter(prefix="/countries")
async def list_countries(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_countries.view", "cat_countries.view"], require_all=False)
"""Endpoint público para obtener lista de países - no requiere autenticación"""
skip = (page - 1) * page_size
query = db.query(Country)
@@ -53,8 +49,6 @@ async def get_country(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_countries", "view")
obj = db.query(Country).filter(Country.m3_key == m3_key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -65,10 +59,8 @@ async def get_country(
async def create_country(
data: CountryDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_countries", "create")
obj = Country(**data.dict())
db.add(obj)
db.commit()
@@ -81,10 +73,8 @@ async def update_country(
m3_key: str,
data: CountryDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_countries", "edit")
obj = db.query(Country).filter(Country.m3_key == m3_key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -99,10 +89,8 @@ async def update_country(
async def delete_country(
m3_key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_countries", "delete")
obj = db.query(Country).filter(Country.m3_key == m3_key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
@@ -16,13 +16,10 @@ router = APIRouter(prefix="/currency-types")
async def list_currency_types(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_currency_types.view", "cat_currency.view"], require_all=False)
skip = (page - 1) * page_size
query = db.query(CurrencyType)
@@ -51,8 +48,6 @@ async def get_currency_type(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_currency_types", "view")
obj = db.query(CurrencyType).filter(CurrencyType.code == code).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -63,10 +58,8 @@ async def get_currency_type(
async def create_currency_type(
data: CurrencyTypeDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_currency_types", "create")
obj = CurrencyType(**data.dict())
db.add(obj)
db.commit()
@@ -79,10 +72,8 @@ async def update_currency_type(
code: str,
data: CurrencyTypeDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_currency_types", "edit")
obj = db.query(CurrencyType).filter(CurrencyType.code == code).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -97,10 +88,8 @@ async def update_currency_type(
async def delete_currency_type(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_currency_types", "delete")
obj = db.query(CurrencyType).filter(CurrencyType.code == code).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/customs-sections")
def list_customs_sections(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.view"])
skip = (page - 1) * page_size
query = db.query(CustomsSection)
@@ -47,12 +44,9 @@ def list_customs_sections(
@router.get("/{customs_code}", response_model=CustomsSectionDTO)
def get_customs_section(
customs_code: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.view"])
obj = (
db.query(CustomsSection)
.filter(CustomsSection.customs_code == customs_code)
@@ -66,12 +60,9 @@ def get_customs_section(
@router.post("/", response_model=CustomsSectionDTO, status_code=201)
def create_customs_section(
data: CustomsSectionDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"])
obj = CustomsSection(**data.dict())
db.add(obj)
db.commit()
@@ -83,12 +74,9 @@ def create_customs_section(
def update_customs_section(
customs_code: str,
data: CustomsSectionDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"])
obj = (
db.query(CustomsSection)
.filter(CustomsSection.customs_code == customs_code)
@@ -106,12 +94,9 @@ def update_customs_section(
@router.delete("/{customs_code}", status_code=204)
def delete_customs_section(
customs_code: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_sections.edit"])
obj = (
db.query(CustomsSection)
.filter(CustomsSection.customs_code == customs_code)

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/customs-warehouses")
def list_customs_warehouses(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_customs_warehouses.view", "cat_warehouses.view"], require_all=False)
skip = (page - 1) * page_size
query = db.query(CustomsWarehouse)

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
@@ -22,8 +22,6 @@ async def list_identifiers(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_identifiers", "view")
skip = (page - 1) * page_size
query = db.query(IdentifierCatalog)
@@ -67,8 +65,6 @@ async def get_identifier(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_identifiers", "view")
obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -79,10 +75,8 @@ async def get_identifier(
async def create_identifier(
data: IdentifierDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_identifiers", "create")
# Check if already exists
existing = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == data.key).first()
if existing:
@@ -100,10 +94,8 @@ async def update_identifier(
key: str,
data: IdentifierDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_identifiers", "edit")
obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -118,10 +110,8 @@ async def update_identifier(
async def delete_identifier(
key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource as validate_perm
validate_perm(current_user, "cat_identifiers", "delete")
obj = db.query(IdentifierCatalog).filter(IdentifierCatalog.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -16,15 +16,12 @@ router = APIRouter(prefix="/incoterms")
async def list_incoterms(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
code: str = Query(None, description="Filtrar por clave"),
description: str = Query(None, description="Filtrar por descripción"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_incoterms.view", "cat_incoterms.view"], require_all=False)
skip = (page - 1) * page_size
query = db.query(Incoterm)

View File

@@ -16,15 +16,11 @@ router = APIRouter(prefix="/invoice-types")
def list_invoice_types(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
company_id: int = Query(..., description="ID de la empresa"),
type: Optional[str] = Query(None, description="Filter by type"),
operation: Optional[str] = Query(None, description="Filter by operation type (imp, exp, both)"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_invoice_types.view", "cat_inv_types.view"], require_all=False)
query = db.query(InvoiceType)
if search:

View File

@@ -16,14 +16,11 @@ router = APIRouter(prefix="/material-types")
async def list_material_types(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
type: str = Query(None, description="Filtrar por tipo (ACTIVO FIJO, MATERIALES, PRODUCTOS)"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_material_types.view", "cat_material_types.view"], require_all=False)
skip = (page - 1) * page_size
query = db.query(MaterialType)

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/payment-methods")
def list_payment_methods(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_payments.view"])
skip = (page - 1) * page_size
query = db.query(PaymentMethod)
@@ -47,12 +44,9 @@ def list_payment_methods(
@router.get("/{key}", response_model=PaymentMethodDTO)
def get_payment_method(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_payments.view"])
obj = db.query(PaymentMethod).filter(PaymentMethod.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/pedimento-codes")
def list_pedimento_codes(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_codes.view"])
skip = (page - 1) * page_size
query = db.query(PedimentoCode)
@@ -47,12 +44,9 @@ def list_pedimento_codes(
@router.get("/{code}", response_model=PedimentoCodeDTO)
def get_pedimento_code(
code: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_codes .view"])
obj = db.query(PedimentoCode).filter(PedimentoCode.code == code).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
@@ -16,13 +16,10 @@ router = APIRouter(prefix="/pedimento-regimens")
def list_pedimento_regimens(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"])
skip = (page - 1) * page_size
query = db.query(RegimenPedimento)
@@ -47,12 +44,9 @@ def list_pedimento_regimens(
@router.get("/{key}", response_model=RegimenPedimentoDTO)
def get_pedimento_regimen(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.view"])
obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -62,13 +56,9 @@ def get_pedimento_regimen(
@router.post("/", response_model=RegimenPedimentoDTO, status_code=201)
def create_pedimento_regimen(
data: RegimenPedimentoDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
# Mutation for reference data usually restricted to admin role or specific perm
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"])
obj = RegimenPedimento(**data.model_dump())
db.add(obj)
db.commit()
@@ -80,12 +70,9 @@ def create_pedimento_regimen(
def update_pedimento_regimen(
key: str,
data: RegimenPedimentoDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"])
obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -99,12 +86,9 @@ def update_pedimento_regimen(
@router.delete("/{key}", status_code=204)
def delete_pedimento_regimen(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(has_role("admin")),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_pedimento_regimens.edit"])
obj = db.query(RegimenPedimento).filter(RegimenPedimento.code == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")

View File

@@ -14,15 +14,11 @@ router = APIRouter(prefix="/pedimento-transport-catalog")
@router.get("/", response_model=Dict[str, Any])
async def list_pedimento_transport_catalog(
company_id: int = Query(..., description="ID de la empresa"),
page: int = Query(1, ge=1, description="Numero de pagina"),
page_size: int = Query(100, ge=1, le=200, description="Tamano de pagina"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"])
skip = (page - 1) * page_size
query = db.query(PedimentoTransportCatalog)
@@ -47,14 +43,7 @@ async def list_pedimento_transport_catalog(
@router.get("/{code}", response_model=PedimentoTransportCatalogDTO)
async def get_pedimento_transport_catalog(
code: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"])
async def get_pedimento_transport_catalog(code: str, db: Session = Depends(get_core_db)):
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)
@@ -68,12 +57,9 @@ async def get_pedimento_transport_catalog(
@router.post("/", response_model=PedimentoTransportCatalogDTO, status_code=201)
async def create_pedimento_transport_catalog(
data: PedimentoTransportCatalogDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
user=Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"])
obj = PedimentoTransportCatalog(**data.model_dump())
db.add(obj)
db.commit()
@@ -85,12 +71,9 @@ async def create_pedimento_transport_catalog(
async def update_pedimento_transport_catalog(
code: str,
data: PedimentoTransportCatalogDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
user=Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"])
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)
@@ -108,12 +91,9 @@ async def update_pedimento_transport_catalog(
@router.delete("/{code}", status_code=204)
async def delete_pedimento_transport_catalog(
code: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
user=Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["pedimentos_anexo22.view"])
obj = (
db.query(PedimentoTransportCatalog)
.filter(PedimentoTransportCatalog.code == code)

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/states")
async def list_states(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_states.view"])
skip = (page - 1) * page_size
query = db.query(State)

View File

@@ -1,7 +1,7 @@
from typing import Any, Dict
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
@@ -16,14 +16,9 @@ router = APIRouter(prefix="/transport-modes")
async def list_transport_modes(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
# 🛡️ Permiso de Lectura (Listado)
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.view"])
skip = (page - 1) * page_size
query = db.query(TransportMode)
@@ -46,15 +41,7 @@ async def list_transport_modes(
@router.get("/{key}", response_model=TransportModeDTO)
async def get_transport_mode(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
# 🛡️ Permiso de Lectura (Individual)
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.view"])
async def get_transport_mode(key: str, db: Session = Depends(get_core_db)):
obj = db.query(TransportMode).filter(TransportMode.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -64,13 +51,9 @@ async def get_transport_mode(
@router.post("/", response_model=TransportModeDTO, status_code=201)
async def create_transport_mode(
data: TransportModeDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
user=Depends(get_current_user),
):
# 🛡️ Permiso de Creación
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.create"])
obj = TransportMode(**data.dict())
db.add(obj)
db.commit()
@@ -82,13 +65,9 @@ async def create_transport_mode(
async def update_transport_mode(
key: str,
data: TransportModeDTO,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
user=Depends(get_current_user),
):
# 🛡️ Permiso de Edición
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.edit"])
obj = db.query(TransportMode).filter(TransportMode.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
@@ -101,17 +80,11 @@ async def update_transport_mode(
@router.delete("/{key}", status_code=204)
async def delete_transport_mode(
key: str,
company_id: int = Query(..., description="ID de la empresa"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
key: str, db: Session = Depends(get_core_db), user=Depends(get_current_user)
):
# 🛡️ Permiso de Borrado
validate_access_to_resource(db, company_id, current_user, required_permissions=["ref_transport_modes.delete"])
obj = db.query(TransportMode).filter(TransportMode.key == key).first()
if not obj:
raise HTTPException(status_code=404, detail="Not found")
db.delete(obj)
db.commit()
return None
return None

View File

@@ -16,13 +16,9 @@ router = APIRouter(prefix="/transport-types")
def list_transport_types(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_transport_types.view"])
skip = (page - 1) * page_size
query = db.query(TransportType)

View File

@@ -16,13 +16,10 @@ router = APIRouter(prefix="/valuation-methods")
async def list_valuation_methods(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
company_id: int = Query(..., description="ID de la empresa"),
search: str = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
from core.security import validate_access_to_resource
validate_access_to_resource(db, company_id, current_user, ["ref_valuation_methods.view", "cat_valuation.view"], require_all=False)
skip = (page - 1) * page_size
query = db.query(ValuationMethod)

View File

@@ -106,7 +106,14 @@ class ExpedienteArchivosApi {
async list(
companyId: string | number,
params?: Record<string, string | number | undefined>
params?: {
page?: number;
page_size?: number;
search?: string;
status?: string;
rfc_consulta?: string;
e_document?: string;
}
): Promise<ApiResponse<ExpedienteArchivoListResponse>> {
const queryParams = new URLSearchParams({ company_id: companyId.toString() });
if (params) {

View File

@@ -107,6 +107,16 @@ export function createColumns(
header: 'Tipo Documento',
cell: ({ row }) => row.original.tipo_documento || '-'
},
{
accessorKey: 'rfc_consulta',
header: 'RFC Consulta',
cell: ({ row }) => row.original.rfc_consulta || '-'
},
{
accessorKey: 'nombre_archivo',
header: 'Archivo',
cell: ({ row }) => row.original.nombre_archivo || '-'
},
{
accessorKey: 'e_document',
header: 'E-Document',

View File

@@ -320,6 +320,18 @@
/>
</div>
<div class="space-y-2">
<Label for="rfc_consulta">{m['sidebar.digitalizacion.form_rfc_consulta']()}</Label>
<Input
id="rfc_consulta"
value={formData.rfc_consulta ?? ''}
oninput={(e) => (formData.rfc_consulta = (e.target as HTMLInputElement).value.toUpperCase())}
placeholder="RFC para consulta"
maxlength={13}
disabled={loading}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="archivo_digitalizado_en">{m['sidebar.digitalizacion.form_archivo_digitalizado_en']()} *</Label>
<FilePickerInput

View File

@@ -290,10 +290,6 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.customs_warehouses"](),
url: "/dashboard/reference_data/customs_warehouses",
},
{
title: m["sidebar.general_catalogs.doda"](),
url: "/dashboard/general_catalogs/doda",
},
{
title: m["sidebar.general_catalogs.prevalidators"](),
url: "/dashboard/general_catalogs/prevalidators",
@@ -513,6 +509,21 @@ export function getSidebarData(): SidebarData {
icon: BadgeCheck,
items: [],
},
{
title: m["sidebar.despacho.title"](),
url: "#",
icon: GalleryVerticalEnd, // Using a suitable icon
items: [
{
title: m["sidebar.despacho.doda"](),
url: "/dashboard/despacho/doda",
},
{
title: m["sidebar.despacho.digitalizacion"](),
url: "/dashboard/despacho/digitalizacion",
},
],
},
{
title: m["sidebar.reports.title"](),
url: "#",
@@ -532,12 +543,6 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.digitalizacion.title"](),
url: "/dashboard/digitalizacion",
icon: FolderArchive,
items: [],
},
{
title: m["sidebar.reference_data.configuracion"](),
url: "#",

View File

@@ -16,10 +16,6 @@
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import type { ApiError } from '$lib/utils/error-handler';
import { authStore, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import DataTable from '$lib/components/dashboard/clients_and_providers/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/clients_and_providers/columns';
// Los datos iniciales vienen del servidor
let { data }: { data: any } = $props();
@@ -33,38 +29,29 @@
let currentPage = $state(data.page || 1);
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let hasMore = $derived(items.length < totalItems);
// Filter state
let searchName = $state('');
let searchRfc = $state('');
let searchType = $state<string>($page.url.searchParams.get('type') || 'both');
let filterDebounce: ReturnType<typeof setTimeout> | null = null;
// Estado para el diálogo de crear
let showCreateDialog = $state(false);
let error = $state<string | ApiError | null>(data.error || null);
// Permisos
const canView = $derived(userHasPermission($authStore.user, 'partners_mgmt.view'));
const canCreate = $derived(userHasPermission($authStore.user, 'partners_mgmt.create'));
const canEdit = $derived(userHasPermission($authStore.user, 'partners_mgmt.edit'));
const canDelete = $derived(userHasPermission($authStore.user, 'partners_mgmt.delete'));
// --- Lifecycle ---
onMount(() => {
if (browser) {
const getCookie = (name: string): string | null => {
// Sincronizar token de cookies a localStorage si es necesario
const getCookie = (name: string) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken)
localStorage.setItem('access_token', cookieToken);
@@ -77,7 +64,7 @@
// --- Actions ---
async function loadItems(pageToLoad = 1, append = false) {
async function loadItems(pageToLoad = 1) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -85,10 +72,13 @@
try {
const filters: any = {};
if (searchType !== 'both') filters.type = searchType;
const trimmedName = searchName.trim();
const trimmedRfc = searchRfc.trim();
if (trimmedName) filters.name = trimmedName;
if (trimmedRfc) filters.rfc = trimmedRfc;
// Note: The API technically supports name/rfc fitlering if backend implements it.
// Assuming backend supports 'name' and 'rfc' query params based on standard patterns,
// or we filter client side if the list is small.
// Given pagination, we should try sending them. If backend ignores them, we might need client filtering.
// Ideally backend should handle this. I will assume backend filters for now or add query params.
if (searchName) filters.name = searchName;
if (searchRfc) filters.rfc = searchRfc;
const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters);
@@ -103,11 +93,7 @@
}
if (response.data) {
if (append) {
items = [...items, ...response.data.items];
} else {
items = response.data.items;
}
items = response.data.items;
totalItems = response.data.total;
currentPage = response.data.page;
}
@@ -119,13 +105,13 @@
}
}
async function loadMore() {
if (isLoading || !hasMore) return;
await loadItems(currentPage + 1, true);
}
function handleTypeChange(value: string) {
searchType = value;
loadItems(1);
}
function handleSearch() {
loadItems(1);
}
function selectItem(item: ClientProvider) {
@@ -142,10 +128,6 @@
if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`);
}
function handleSearch() {
loadItems(1);
}
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaSocios } from '$lib/config/shortcuts/dashboard/clients_and_providers/list';
@@ -173,218 +155,331 @@
recargar: () => loadItems(1)
})
);
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (!browser || !companyId) return;
searchName;
searchRfc;
searchType;
pageSize;
if (filterDebounce) clearTimeout(filterDebounce);
filterDebounce = setTimeout(() => {
selectedItem = null;
loadItems(1);
}, 350);
return () => {
if (filterDebounce) clearTimeout(filterDebounce);
};
});
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
{#if !canView}
<ErrorState
status={403}
error="No tienes permiso para ver esta sección: partners_mgmt.view"
onRetry={() => window.location.reload()}
/>
{:else}
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Socio Comercial</h1>
<p class="text-muted-foreground">Administración de clientes y proveedores</p>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => loadItems(currentPage)}>
<RefreshCw class="h-4 w-4 mr-2 {isLoading ? 'animate-spin' : ''}" />
Actualizar
</Button>
{#if canCreate}
<Button size="sm" href="/dashboard/clients_and_providers/edit">
<Plus class="h-4 w-4 mr-1" />
Nuevo
</Button>
{/if}
{#if canEdit}
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={!selectedItem}
>
Editar
</Button>
{/if}
{#if canDelete}
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{/if}
</div>
</div>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
<!-- Title -->
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold">CLIENTES Y PROVEEDORES</h1>
<p class="text-sm text-muted-foreground">
Gestiona el catálogo de clientes y proveedores de tu empresa
</p>
</div>
{#if error}
<div class="flex-none rounded-md border border-destructive px-4 py-3 text-sm text-destructive">
{typeof error === 'string' ? error : error.detail}
</div>
{/if}
<!-- Error Message -->
{#if error}
<Card.Root
class="bg-card dark:bg-black text-card-foreground dark:text-white border-destructive dark:border-red-800"
>
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>
{typeof error === 'string' ? error : error.detail}
</Card.Description>
</Card.Header>
</Card.Root>
{/if}
<div class="flex-1 flex gap-4 overflow-hidden">
<!-- Left Panel: Table -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Filters -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre, RFC/TAX-ID o tipo</span>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre / Razón Social</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">RFC / TAX-ID</Label>
<Input
bind:value={searchRfc}
placeholder="RFC / TAX-ID..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Tipo</Label>
<Select.Root type="single" value={searchType} onValueChange={handleTypeChange}>
<Select.Trigger class="h-9">
{searchType === 'both'
? 'Todos'
: searchType === 'client'
? 'Clientes'
: 'Proveedores'}
</Select.Trigger>
<Select.Content>
<Select.Item value="both">Todos</Select.Item>
<Select.Item value="client">Clientes</Select.Item>
<Select.Item value="provider">Proveedores</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex items-end">
<Button variant="secondary" size="sm" class="w-full" onclick={handleSearch}>
Buscar
</Button>
</div>
</div>
<div class="flex-1 flex gap-4 overflow-hidden">
<!-- Left Panel: Table -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Filters -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre, RFC/TAX-ID o tipo</span>
</div>
</div>
<!-- Table -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-muted/30">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{totalItems} registros
</span>
<Button variant="outline" size="sm" onclick={() => loadItems(1)}>
<RefreshCw class="h-4 w-4 mr-2" />
Actualizar
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre / Razón Social</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">RFC / TAX-ID</Label>
<Input
bind:value={searchRfc}
placeholder="RFC / TAX-ID..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Tipo</Label>
<Select.Root type="single" value={searchType} onValueChange={handleTypeChange}>
<Select.Trigger class="h-9">
{searchType === 'both'
? 'Todos'
: searchType === 'client'
? 'Clientes'
: 'Proveedores'}
</Select.Trigger>
<Select.Content>
<Select.Item value="both">Todos</Select.Item>
<Select.Item value="client">Clientes</Select.Item>
<Select.Item value="provider">Proveedores</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex items-end">
<Button variant="secondary" size="sm" class="w-full" onclick={handleSearch}>
Buscar
</Button>
</div>
</div>
<div class="flex-1 overflow-hidden bg-card">
<DataTable
data={items}
columns={createColumns(() => loadItems(1))}
loading={isLoading}
{hasMore}
{loadMore}
onRowClick={(row) => selectItem(row as ClientProvider)}
selectedId={selectedItem?.id}
/>
</div>
</div>
</div>
<!-- Right Panel: Details -->
<div
class="w-80 flex-none flex flex-col border rounded-xl bg-card shadow-sm overflow-hidden"
>
<div class="p-4 border-b bg-muted/30">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
Detalles del Registro
</p>
<h2
class="text-xl font-black font-mono tracking-tighter truncate"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-medium text-muted-foreground">{taxIdOrRfcLabel(selectedItem)}:</span>
<span class="text-xs font-mono text-muted-foreground">{selectedItem?.rfc || ''}</span>
<!-- Table -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-muted/30">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{totalItems} registros
</span>
<Button variant="outline" size="sm" onclick={() => loadItems(currentPage)}>
<RefreshCw class="h-4 w-4 mr-2" />
Actualizar
</Button>
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Users size={10} /> Tipo
</Label>
<p class="text-sm font-medium capitalize">{selectedItem.client_or_provider}</p>
</div>
{#if selectedItem.address}
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1 text-muted-foreground">
<p>{selectedItem.address.streets || ''} {selectedItem.address.exterior_number || ''}</p>
<p>{selectedItem.address.neighborhood || ''}</p>
<p>{selectedItem.address.city || ''}, {selectedItem.address.state || ''}</p>
<p>{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}</p>
</div>
</div>
<div class="flex-1 overflow-auto bg-card">
<table class="w-full text-sm">
<thead class="bg-muted text-muted-foreground border-b">
<tr>
<th class="px-3 py-2 text-left w-8">#</th>
<th class="px-3 py-2 text-left">RFC / TAX-ID</th>
<th class="px-3 py-2 text-left">Nombre</th>
<th class="px-3 py-2 text-left">Tipo</th>
<th class="px-3 py-2 text-left">Estatus</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr
><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td
></tr
>
{:else if items.length === 0}
<tr
><td colspan="5" class="text-center py-8 text-muted-foreground"
>No se encontraron registros</td
></tr
>
{:else}
{#each items as item (item.id)}
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id ===
item.id
? 'bg-muted'
: ''}"
onclick={() => selectItem(item)}
>
<td class="px-3 py-2 font-mono text-xs text-muted-foreground">{item.id}</td>
<td class="px-3 py-2 font-mono font-medium">{item.rfc}</td>
<td class="px-3 py-2">{item.name}</td>
<td class="px-3 py-2">
{#if item.client_or_provider === 'client'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>Cliente</span
>
{:else if item.client_or_provider === 'provider'}
<span
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700"
>Proveedor</span
>
{:else}
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
>Ambos</span
>
{/if}
</td>
<td class="px-3 py-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {item.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}"
>
{item.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
</tr>
{/each}
{/if}
</div>
{:else}
<div class="h-full flex flex-col items-center justify-center text-center text-muted-foreground opacity-50">
<Users class="h-12 w-12 mb-3" />
<p class="text-sm">Selecciona un registro</p>
</div>
{/if}
</tbody>
</table>
</div>
<!-- Simple Pagination Controls -->
<div class="p-2 border-t flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1 || isLoading}
onclick={() => loadItems(currentPage - 1)}
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={items.length < pageSize || isLoading}
onclick={() => loadItems(currentPage + 1)}
>
Siguiente
</Button>
</div>
</div>
</div>
{/if}
<!-- Right Panel: Details -->
<div
class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden"
>
<div class="p-4 border-b bg-card">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
Detalles del Registro
</p>
<h2
class="text-xl font-black font-mono tracking-tighter truncate"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-medium text-muted-foreground">{taxIdOrRfcLabel(selectedItem)}:</span>
<span class="text-xs font-mono text-muted-foreground">{selectedItem?.rfc || ''}</span>
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Users size={10} /> Tipo
</Label>
<p class="text-sm font-medium capitalize">{selectedItem.client_or_provider}</p>
</div>
{#if selectedItem.address}
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<p>
{selectedItem.address.streets || ''}
{selectedItem.address.exterior_number || ''}
{selectedItem.address.interior_number
? 'Int ' + selectedItem.address.interior_number
: ''}
</p>
<p>{selectedItem.address.neighborhood || ''}</p>
<p>{selectedItem.address.city || ''}, {selectedItem.address.state || ''}</p>
<p>
{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}
</p>
</div>
</div>
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Hash size={10} /> Contacto
</Label>
{#if selectedItem.address.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.address.email}</span>
</div>
{/if}
{#if selectedItem.address.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.address.phone}</span>
</div>
{/if}
</div>
{:else}
<div
class="p-4 rounded-md bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-900"
>
<p class="text-xs text-yellow-600 dark:text-yellow-400">Sin dirección registrada</p>
</div>
{/if}
{#if selectedItem.programs}
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Building2 size={10} /> Programas
</Label>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-xs text-muted-foreground block">Programa</span>
<span class="font-medium">{selectedItem.programs.program || '-'}</span>
</div>
<div>
<span class="text-xs text-muted-foreground block">Número</span>
<span class="font-medium">{selectedItem.programs.program_number || '-'}</span>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50"
>
<Building2 class="h-12 w-12 mb-3" />
<p class="text-sm">Selecciona un registro</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Sticky Footer Actions -->
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
>
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<div class="flex justify-end gap-2">
<Button size="sm" href="/dashboard/clients_and_providers/edit">
<Plus class="h-4 w-4 mr-1" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
</div>
</div>
</div>

View File

@@ -13,15 +13,13 @@
import { page } from '$app/stores';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/dashboard/customs_brokers/list';
import { authStore, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import {
customsSectionsApi,
type CustomsSection
} from '$lib/api/dashboard/reference_data/customs_sections';
import SectionsDataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte';
import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js';
import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns';
import * as Card from '$lib/components/ui/card';
// Specialized Broker Components
@@ -70,12 +68,6 @@
let sectionsPage = $state(1);
let hasMoreSections = $derived(sections.length < totalSections);
// Permisos
const canView = $derived(userHasPermission($authStore.user, 'customs_brokers.view'));
const canCreate = $derived(userHasPermission($authStore.user, 'customs_brokers.create'));
const canEdit = $derived(userHasPermission($authStore.user, 'customs_brokers.edit'));
const canDelete = $derived(userHasPermission($authStore.user, 'customs_brokers.delete'));
// --- Lifecycle ---
onMount(() => {
if (browser) {
@@ -196,266 +188,256 @@
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
{#if !canView}
<ErrorState
status={403}
error="No tienes permiso para ver esta sección: customs_brokers.view"
onRetry={() => window.location.reload()}
/>
{:else}
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Gestión Aduanal</h1>
<p class="text-muted-foreground">Administración de Agentes y Secciones Aduanales</p>
</div>
<div class="flex items-center gap-3">
{#if canCreate}
<Button size="sm" class="h-9" href="/dashboard/customs_brokers/edit/new">
<Plus class="mr-2 h-4 w-4" />
Nuevo
</Button>
{/if}
{#if canEdit}
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={!selectedItem}
>
Editar
</Button>
{/if}
{#if canDelete}
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{/if}
</div>
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Gestión Aduanal</h1>
<p class="text-muted-foreground">Administración de Agentes y Secciones Aduanales</p>
</div>
</div>
<Tabs.Root bind:value={activeTab} class="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs.List class="mb-4 w-full justify-start rounded-none border-b bg-transparent p-0">
<Tabs.Trigger
value="brokers"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
Agentes Aduanales
</Tabs.Trigger>
<Tabs.Trigger
value="customs"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
Secciones Aduanales
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content
<Tabs.Root bind:value={activeTab} class="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs.List class="mb-4 w-full justify-start rounded-none border-b bg-transparent p-0">
<Tabs.Trigger
value="brokers"
class="mt-0 flex flex-1 gap-4 overflow-hidden data-[state=inactive]:hidden"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div class="rounded-md border bg-background">
<div class="space-y-4 p-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre o patente</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9 bg-card"
oninput={handleSearch}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">Patente / Clave</Label>
<Input
bind:value={searchPatent}
placeholder="Num. Patente..."
class="h-9 bg-card"
oninput={handleSearch}
/>
</div>
<div class="flex items-end"></div>
</div>
</div>
</div>
Agentes Aduanales
</Tabs.Trigger>
<Tabs.Trigger
value="customs"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
Secciones Aduanales
</Tabs.Trigger>
</Tabs.List>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border bg-background">
<div class="flex items-center justify-between border-b bg-background/95 p-3">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{filteredItems.length} registros
</span>
<Button variant="outline" size="sm" onclick={loadItems}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
<Tabs.Content
value="brokers"
class="mt-0 flex flex-1 gap-4 overflow-hidden data-[state=inactive]:hidden"
>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div class="rounded-md border bg-background">
<div class="space-y-4 p-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre o patente</span>
</div>
<div class="flex-1 overflow-auto bg-background">
<BrokerDataTable
data={paginatedItems}
columns={brokerColumns}
onRowClick={selectItem}
selectedId={selectedItem?.broker_key}
idField="broker_key"
/>
</div>
{#if totalItems > pageSize}
<div class="flex justify-end gap-2 border-t p-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onclick={() => currentPage--}
>
Anterior
</Button>
<span class="flex items-center px-2 text-xs text-muted-foreground">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage * pageSize >= totalItems}
onclick={() => currentPage++}
>
Siguiente
</Button>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9 bg-card"
oninput={handleSearch}
/>
</div>
{/if}
<div class="space-y-2">
<Label class="text-xs">Patente / Clave</Label>
<Input
bind:value={searchPatent}
placeholder="Num. Patente..."
class="h-9 bg-card"
oninput={handleSearch}
/>
</div>
<div class="flex items-end"></div>
</div>
</div>
</div>
<div class="flex w-96 flex-none flex-col overflow-hidden rounded-xl border bg-muted/30 shadow-sm">
<div class="border-b bg-card p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Detalles del Agente
</p>
<h2
class="truncate font-mono text-xl font-black tracking-tighter"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="mt-1 flex items-center gap-2">
<span class="font-mono text-xs text-muted-foreground"
>Patente: {selectedItem?.broker_key || ''}</span
>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border bg-background">
<div class="flex items-center justify-between border-b bg-background/95 p-3">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{filteredItems.length} registros
</span>
<Button variant="outline" size="sm" onclick={loadItems}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
{#if selectedItem}
<!-- Buttons moved to header -->
{/if}
</div>
<div class="flex-1 space-y-6 overflow-auto bg-card p-5">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="flex-1 overflow-auto bg-background">
<BrokerDataTable
data={paginatedItems}
columns={brokerColumns}
onRowClick={selectItem}
selectedId={selectedItem?.broker_key}
idField="broker_key"
/>
</div>
{#if totalItems > pageSize}
<div class="flex justify-end gap-2 border-t p-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onclick={() => currentPage--}
>
Anterior
</Button>
<span class="flex items-center px-2 text-xs text-muted-foreground">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage * pageSize >= totalItems}
onclick={() => currentPage++}
>
Siguiente
</Button>
</div>
{/if}
</div>
</div>
<div class="flex w-96 flex-none flex-col overflow-hidden rounded-xl border bg-muted/30 shadow-sm">
<div class="border-b bg-card p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Detalles del Agente
</p>
<h2
class="truncate font-mono text-xl font-black tracking-tighter"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="mt-1 flex items-center gap-2">
<span class="font-mono text-xs text-muted-foreground"
>Patente: {selectedItem?.broker_key || ''}</span
>
</div>
</div>
<div class="flex-1 space-y-6 overflow-auto bg-card p-5">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<FileText size={10} /> Licencia / Autorización
</Label>
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
</div>
{#if selectedItem.tax_id}
<div class="space-y-1">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<FileText size={10} /> Licencia / Autorización
<Hash size={10} /> RFC / Tax ID
</Label>
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
<p class="font-mono text-sm">{selectedItem.tax_id}</p>
</div>
{/if}
{#if selectedItem.tax_id}
<div class="space-y-1">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<Hash size={10} /> RFC / Tax ID
</Label>
<p class="font-mono text-sm">{selectedItem.tax_id}</p>
<div class="space-y-3 border-t pt-4">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<MapPin size={10} /> Dirección
</Label>
<div class="space-y-1 text-sm">
<p>{selectedItem.address || ''}</p>
<p>
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
</p>
<p>
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
</p>
</div>
</div>
<div class="space-y-3 border-t pt-4">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<Phone size={10} /> Contacto
</Label>
{#if selectedItem.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.email}</span>
</div>
{/if}
<div class="space-y-3 border-t pt-4">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<MapPin size={10} /> Dirección
</Label>
<div class="space-y-1 text-sm">
<p>{selectedItem.address || ''}</p>
<p>
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
</p>
<p>
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
</p>
{#if selectedItem.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.phone}</span>
</div>
</div>
<div class="space-y-3 border-t pt-4">
<Label
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<Phone size={10} /> Contacto
</Label>
{#if selectedItem.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.email}</span>
</div>
{/if}
{#if selectedItem.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.phone}</span>
</div>
{/if}
{#if selectedItem.contact}
<div class="mt-2 text-xs text-muted-foreground">
<span class="font-bold">Contacto:</span>
{selectedItem.contact}
</div>
{/if}
</div>
{/if}
{#if selectedItem.contact}
<div class="mt-2 text-xs text-muted-foreground">
<span class="font-bold">Contacto:</span>
{selectedItem.contact}
</div>
{/if}
</div>
{:else}
<div
class="flex h-full flex-col items-center justify-center text-center text-muted-foreground opacity-50"
>
<Building2 class="mb-3 h-12 w-12" />
<p class="text-sm">Selecciona un agente</p>
</div>
{/if}
</div>
</div>
{:else}
<div
class="flex h-full flex-col items-center justify-center text-center text-muted-foreground opacity-50"
>
<Building2 class="mb-3 h-12 w-12" />
<p class="text-sm">Selecciona un agente</p>
</div>
{/if}
</div>
</Tabs.Content>
</div>
</Tabs.Content>
<Tabs.Content value="customs" class="mt-0 flex-1 overflow-auto data-[state=inactive]:hidden">
<Card.Root class="flex h-full flex-col border-none shadow-none">
<Card.Content class="flex-1 p-0">
<SectionsDataTable
data={sections}
columns={sectionsColumns}
loading={loadingSections}
hasMore={hasMoreSections}
loadMore={loadMoreSections}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
<Tabs.Content value="customs" class="mt-0 flex-1 overflow-auto data-[state=inactive]:hidden">
<Card.Root class="flex h-full flex-col border-none shadow-none">
<Card.Content class="flex-1 p-0">
<SectionsDataTable
data={sections}
columns={sectionsColumns}
loading={loadingSections}
hasMore={hasMoreSections}
loadMore={loadMoreSections}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
<div class="h-4"></div>
{/if}
<div class="h-20"></div>
</div>
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if activeTab === 'brokers'}
<Button size="sm" href="/dashboard/customs_brokers/edit/new">
<Plus class="mr-1 h-4 w-4" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{:else}
<Button size="sm" onclick={() => toast.info('Pendiente')}>
<Plus class="mr-1 h-4 w-4" />
Nueva Sección
</Button>
{/if}
</div>
</div>
</div>
<DeleteDialog bind:open={showDeleteDialog} broker={selectedItem} onSuccess={handleActionSuccess} />

View File

@@ -7,7 +7,9 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2 } from 'lucide-svelte';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Plus, RefreshCw, FileCheck2, Download, FolderArchive, Pencil, Trash2, Filter } from 'lucide-svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte';
@@ -31,6 +33,10 @@
let hasMore = $derived(data.length < totalItems);
let search = $state($page.url.searchParams.get('search') || '');
let searchEDocument = $state('');
let searchRFC = $state('');
let searchStatus = $state('all');
let showFilters = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
// Selección de filas
@@ -63,7 +69,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt
const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
search: search || undefined
search: search || undefined,
e_document: searchEDocument || undefined,
rfc_consulta: searchRFC || undefined,
status: searchStatus === 'all' ? undefined : searchStatus
});
if (res.data) {
data = res.data.items;
@@ -85,7 +94,10 @@ const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedIt
const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
search: search || undefined
search: search || undefined,
e_document: searchEDocument || undefined,
rfc_consulta: searchRFC || undefined,
status: searchStatus === 'all' ? undefined : searchStatus
});
if (res.data?.items) {
data = [...data, ...res.data.items];
@@ -393,6 +405,15 @@ async function handleDownloadArtifact(
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>{m['sidebar.digitalizacion.table_title']()}</Card.Title>
<div class="flex items-center gap-2">
<Button
variant={showFilters ? 'secondary' : 'outline'}
size="sm"
class="h-9"
onclick={() => (showFilters = !showFilters)}
>
<Filter class="mr-2 h-4 w-4" />
Filtros
</Button>
<Input
placeholder="{m['sidebar.digitalizacion.search_placeholder']()} ..."
class="h-9 w-56 bg-card"
@@ -401,6 +422,74 @@ async function handleDownloadArtifact(
/>
</div>
</div>
{#if showFilters}
<div class="mt-4 grid grid-cols-1 gap-4 rounded-lg border bg-muted/30 p-4 sm:grid-cols-2 md:grid-cols-4">
<!-- E-Document -->
<div class="space-y-2">
<Label class="text-xs font-medium uppercase tracking-wider text-muted-foreground">E-Document</Label>
<Input
placeholder="Buscar por E-Document..."
class="h-8 bg-background text-sm"
bind:value={searchEDocument}
oninput={handleSearch}
/>
</div>
<!-- RFC -->
<div class="space-y-2">
<Label class="text-xs font-medium uppercase tracking-wider text-muted-foreground">RFC Consulta</Label>
<Input
placeholder="Buscar por RFC..."
class="h-8 bg-background text-sm"
bind:value={searchRFC}
oninput={handleSearch}
/>
</div>
<!-- Status -->
<div class="space-y-2">
<Label class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Estatus</Label>
<Select.Root
type="single"
value={searchStatus}
onValueChange={(v) => {
searchStatus = v || 'all';
loadData();
}}
>
<Select.Trigger class="h-8 bg-background text-sm">
<Select.Value placeholder="Todos los estatus" />
</Select.Trigger>
<Select.Content>
<Select.Item value="all">Todos</Select.Item>
<Select.Item value="pending">Pendiente</Select.Item>
<Select.Item value="processing">Procesando</Select.Item>
<Select.Item value="success">Completado</Select.Item>
<Select.Item value="failed">Fallido</Select.Item>
</Select.Content>
</Select.Root>
</div>
<!-- Limpiar -->
<div class="flex items-end">
<Button
variant="ghost"
size="sm"
class="h-8 w-full text-xs"
onclick={() => {
searchEDocument = '';
searchRFC = '';
searchStatus = 'all';
search = '';
loadData();
}}
>
Limpiar filtros
</Button>
</div>
</div>
{/if}
</Card.Header>
<Card.Content class="p-0 flex-1 min-h-0 overflow-hidden">
{#if loading && data.length === 0}

View File

@@ -2,67 +2,80 @@ import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }
};
}
const pUser = parentData.user as
| { preferred_username?: string; name?: string; email?: string }
| undefined
| null;
const defaultLastUser =
(pUser?.preferred_username?.trim() || pUser?.name?.trim() || pUser?.email?.trim() || '') || '';
try {
const page = Number(url.searchParams.get('page')) || 1;
const pageSize = Number(url.searchParams.get('pageSize')) || 50;
if (!accessToken) {
return {
error: 'No authenticated',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
try {
const page = Number(url.searchParams.get('page')) || 1;
const pageSize = Number(url.searchParams.get('pageSize')) || 50;
// Obtener company_id de la cookie o usar el primero disponible
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
if (!companyId) {
return {
error: 'No company selected',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }
};
}
if (!companyId) {
return {
error: 'No company selected',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
const filters: Record<string, string> = {};
const integrationNumber = url.searchParams.get('integration_number');
const patent = url.searchParams.get('patent');
const status = url.searchParams.get('status');
const operationType = url.searchParams.get('operation_type');
const filters: Record<string, string> = {};
const integrationNumber = url.searchParams.get('integration_number');
if (integrationNumber) filters.integration_number = integrationNumber;
if (patent) filters.patent = patent;
if (status) filters.status = status;
if (operationType) filters.operation_type = operationType;
if (integrationNumber) filters.integration_number = integrationNumber;
const queryParams = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
});
const queryParams = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
});
const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch);
const response = await authenticatedFetch(
`v1/a76/doda?${queryParams.toString()}`,
{ method: 'GET' },
cookies,
fetch
);
if (!response.ok) {
let errorMsg = 'Failed to load';
try {
const errorData = await response.json();
errorMsg = errorData.detail || errorData.message || errorMsg;
} catch (e) {
// Ignore json parsing error
}
return {
error: errorMsg,
status: response.status,
dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 },
defaultLastUser
};
}
if (!response.ok) {
return {
error: 'Failed to load',
dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
};
}
return { dodas: await response.json() };
} catch (error) {
console.error('Error loading DODAs:', error);
return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
}
const dodasData = await response.json();
return { dodas: dodasData, status: 200, defaultLastUser };
} catch (error: any) {
console.error('Error loading DODAs:', error);
return {
error: error.message || 'Error loading',
status: error.status || 500,
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
};

View File

@@ -1,414 +1,357 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { m } from '$lib/i18n/messages';
import {
Plus,
RefreshCw,
Pencil,
Trash2,
Search,
RotateCcw,
Send,
Loader2,
FileSpreadsheet,
Table
} from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import DodaFormModal from '$lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Pencil, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/list';
import { dodaApi, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
import DodaExportExcelDialog from '$lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte';
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
let { data } = $props();
import {
getDodas,
deleteDoda,
exportDodaPedimentosDetail,
postDodaAlta,
getDodaElegibilidad,
type Doda
} from '$lib/api/dashboard/a76/general_catalogs/doda';
let dialogOpen = $state(false);
let editingItem = $state<Doda | null>(null);
let error = $state<string | null>(data.error || null);
let status = $state<number>(data.status || 200);
let { data } = $props();
// Permisos
const canView = $derived(userHasPermission($currentUser, 'cat_doda.view'));
const canCreate = $derived(userHasPermission($currentUser, 'cat_doda.create'));
const canEdit = $derived(userHasPermission($currentUser, 'cat_doda.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'cat_doda.delete'));
let allDodas = $state<Doda[]>(data.dodas?.items || []);
let dodaPage = $state(data.dodas?.page || 1);
let dodaPageSize = $state(50);
let dodaTotal = $state(data.dodas?.total || 0);
let dodaLoading = $state(false);
let dodaHasMore = $derived(allDodas.length < dodaTotal);
const isError = $derived(!canView || status >= 400 || error);
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
// Atajos
useShortcuts(
'Lista DODA',
obtenerAtajosListaDoda({
manejarNuevo: () => {
if (!canCreate) return;
editingItem = null;
dialogOpen = true;
},
manejarActualizar: () => reloadData()
})
);
let dodaFilterTimeout: ReturnType<typeof setTimeout>;
// Filtros
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
let timeout: ReturnType<typeof setTimeout>;
let selectedDodaIds = $state<(string | number)[]>([]);
const selectedDoda = $derived(
selectedDodaIds.length === 1
? allDodas.find((item) => String(item.id) === String(selectedDodaIds[0])) ?? null
: null
);
const altaVariant = $derived<'doda' | 'pita'>(
selectedDoda && isPitaCustomsClearance(selectedDoda.customs_clearance) ? 'pita' : 'doda'
);
let allItems = $state<Doda[]>(data.dodas?.items || []);
let currentPage = $state(data.dodas?.page || 1);
let pageSize = $state(data.dodas?.page_size || 50);
let totalItems = $state(data.dodas?.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let selectedIds = $state<(string | number)[]>([]);
const selectedItem = $derived(
selectedIds.length === 1
? allItems.find((item) => String(item.id) === String(selectedIds[0])) ?? null
: null
);
let progressDialogOpen = $state(false);
let exportDialogOpen = $state(false);
let currentTaskId = $state('');
let currentVariant = $state<'doda' | 'pita'>('doda');
let altaLoading = $state(false);
let deleteLoading = $state(false);
let pedimentosExportLoading = $state(false);
$effect(() => {
if (data.dodas) {
allItems = data.dodas.items || [];
currentPage = data.dodas.page || 1;
totalItems = data.dodas.total || 0;
pageSize = data.dodas.page_size || pageSize;
}
});
$effect(() => {
if (data.dodas) {
allDodas = data.dodas.items || [];
dodaPage = data.dodas.page || 1;
dodaTotal = data.dodas.total || 0;
}
});
async function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(async () => {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await dodaApi.list(1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error aplicando filtros';
} finally {
loading = false;
}
$effect(() => {
const _ = { ...filters };
clearTimeout(dodaFilterTimeout);
dodaFilterTimeout = setTimeout(() => reloadDodas(), 400);
});
const url = new URL($page.url);
Object.entries(filters).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
});
history.replaceState(history.state, '', url);
}, 500);
}
async function reloadDodas() {
if (!browser) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = res.data.items;
dodaPage = 1;
dodaTotal = res.data.total;
selectedDodaIds = [];
}
} catch {
if (allDodas.length > 0) toast.error('Error al recargar DODAs');
} finally {
dodaLoading = false;
}
}
function clearFilters() {
filters.integration_number = '';
filters.patent = '';
filters.status = '';
filters.operation_type = '';
handleSearch();
}
async function loadMoreDodas() {
if (dodaLoading || !dodaHasMore) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(dodaPage + 1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = [...allDodas, ...res.data.items];
dodaPage++;
dodaTotal = res.data.total;
}
} finally {
dodaLoading = false;
}
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await dodaApi.list(currentPage + 1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = [...allItems, ...payload.items];
currentPage = payload.page || (currentPage + 1);
totalItems = payload.total;
}
} catch (err) {
error = 'Error cargando mas datos';
} finally {
loading = false;
}
}
function handleEdit() {
if (selectedDoda) {
void goto(`/dashboard/general_catalogs/doda?doda_id=${selectedDoda.id}`, { noScroll: true });
}
}
async function reloadData() {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await dodaApi.list(1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error al recargar datos';
} finally {
loading = false;
}
}
async function handleDelete() {
if (deleteLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedDodaIds.length !== 1) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
if (!selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_not_found']());
return;
}
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
deleteLoading = true;
try {
const deletedId = selectedDoda.id;
await deleteDoda(deletedId, companyStore.activeCompany.id);
toast.success(m['sidebar.doda_alta.delete_success']());
const next = applyOptimisticDelete(allDodas, dodaTotal, deletedId);
allDodas = next.items;
dodaTotal = next.total;
selectedDodaIds = [];
await reloadDodas();
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
toast.error(msg);
} finally {
deleteLoading = false;
}
}
function handleSuccess() {
dialogOpen = false;
editingItem = null;
selectedIds = [];
reloadData();
}
async function handleExportPedimentos() {
if (pedimentosExportLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedDodaIds.length !== 1 || !selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
pedimentosExportLoading = true;
try {
await exportDodaPedimentosDetail(selectedDoda.id, companyStore.activeCompany.id, 'xls');
toast.success(m['sidebar.doda_alta.export_pedimentos_success']());
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.export_pedimentos_error']();
toast.error(msg || m['sidebar.doda_alta.export_pedimentos_error']());
} finally {
pedimentosExportLoading = false;
}
}
function handleNew() {
if (!canCreate) return;
const url = new URL($page.url);
url.searchParams.set('doda_id', 'new');
goto(url.toString(), { replaceState: true });
}
function clearFilters() {
filters = { integration_number: '', patent: '', status: '', operation_type: '' };
}
function handleEdit() {
if (!selectedItem || !canEdit) return;
const url = new URL($page.url);
url.searchParams.set('doda_id', selectedItem.id.toString());
goto(url.toString(), { replaceState: true });
}
async function handleAlta() {
if (!selectedDoda || !companyStore.activeCompany) return;
const companyId = companyStore.activeCompany.id;
const dodaId = selectedDoda.id;
const variant = altaVariant;
altaLoading = true;
try {
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
if (elig.error) {
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
return;
}
if (elig.data && !elig.data.can_alta) {
const msgs = elig.data.reasons.map((r) => `• ${r.message}`).join('\n');
toast.error(msgs || m['sidebar.doda_alta.eligibility_error']());
return;
}
const resp = await postDodaAlta(dodaId, companyId, variant);
if (resp.error) {
toast.error(`Error al enviar alta: ${resp.error}`);
return;
}
currentTaskId = resp.data!.task_id;
currentVariant = variant;
progressDialogOpen = true;
} finally {
altaLoading = false;
}
}
async function handleDelete() {
if (!selectedItem || !canDelete || !companyStore.activeCompany) return;
if (confirm('¿Eliminar este registro?')) {
try {
await dodaApi.delete(selectedItem.id, companyStore.activeCompany.id);
selectedIds = [];
reloadData();
} catch (err) {
error = 'Error al eliminar';
}
}
}
function onAltaComplete() {
progressDialogOpen = false;
reloadDodas();
toast.success(m['sidebar.doda_alta.progress_success']());
}
async function handlePrint() {
if (!selectedItem || !companyStore.activeCompany) return;
try {
const { printDoda } = await import('$lib/api/dashboard/a76/general_catalogs/doda');
await printDoda(selectedItem.id, companyStore.activeCompany.id);
} catch (err) {
error = 'Error al imprimir PDF';
}
}
function handleRowClick(row: Doda) {
selectedIds = selectedIds.includes(row.id) ? [] : [row.id];
}
function handleRowDoubleClick(row: Doda) {
if (canEdit) {
const url = new URL($page.url);
url.searchParams.set('doda_id', row.id.toString());
goto(url.toString(), { replaceState: true });
}
}
const columns = $derived(createColumns('es', handleSuccess, { canEdit, canDelete }));
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">{m['sidebar.doda_alta.title']()}</h1>
<p class="text-muted-foreground">{m['sidebar.doda_alta.subtitle']()}</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" onclick={() => reloadDodas()} disabled={dodaLoading}>
<RefreshCw class="mr-2 h-4 w-4 {dodaLoading ? 'animate-spin' : ''}" />
{m['sidebar.doda_alta.refresh']()}
</Button>
<Button size="sm" onclick={() => goto('/dashboard/general_catalogs/doda?doda_id=new')}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.doda_alta.action_new']()}
</Button>
</div>
</div>
<Card.Root class="flex min-h-0 flex-1 flex-col border bg-background">
<Card.Header>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<Card.Title>{m['sidebar.doda_alta.table_title']()}</Card.Title>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[220px_180px_170px_170px_auto] xl:items-center">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={m['sidebar.doda_alta.filter_integration_number']()}
bind:value={filters.integration_number}
class="h-9 bg-card pl-9"
/>
</div>
<Input
placeholder={m['sidebar.doda_alta.filter_patent']()}
bind:value={filters.patent}
class="h-9 bg-card"
/>
<Select.Root
type="single"
value={filters.status}
onValueChange={(v) => (filters.status = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.status || m['sidebar.doda_alta.filter_status']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todos</Select.Item>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="VALIDADO">VALIDADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={filters.operation_type}
onValueChange={(v) => (filters.operation_type = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.operation_type === 'I'
? 'Importación'
: filters.operation_type === 'E'
? 'Exportación'
: m['sidebar.doda_alta.filter_operation_type']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todas</Select.Item>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
<RotateCcw class="mr-2 h-4 w-4" />
Limpiar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 p-0">
<div class="rounded-md border bg-background">
<DataTable
data={allDodas}
columns={createColumns()}
loading={dodaLoading}
hasMore={dodaHasMore}
loadMore={loadMoreDodas}
selectedId={selectedDodaIds.length === 1 ? selectedDodaIds[0] : null}
onRowClick={(row) => {
selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id];
}}
onRowDoubleClick={(item) =>
goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground pt-2">
Mostrando {allDodas.length} de {dodaTotal} registros
<span class="ml-2"></span>
<span class="ml-2">Filtros activos: {Object.values(filters).filter((v) => v !== '').length}</span>
</div>
<div class="h-20"></div>
<div
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={selectedDodaIds.length !== 1}
>
<Pencil size={16} class="mr-2" />
{m['sidebar.doda_alta.action_edit']()}
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedDodaIds.length !== 1 || deleteLoading}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{#if deleteLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_delete']()}
</Button>
<Button variant="outline" size="sm" onclick={() => (exportDialogOpen = true)}>
<FileSpreadsheet size={16} class="mr-2" />
{m['sidebar.doda_alta.action_export_excel']()}
</Button>
<Button
size="sm"
variant="secondary"
class="border border-primary/25 bg-primary/10 text-primary hover:bg-primary/15"
onclick={handleExportPedimentos}
disabled={selectedDodaIds.length !== 1 || pedimentosExportLoading || !companyStore.activeCompany}
title={m['sidebar.doda_alta.action_export_pedimentos']()}
>
{#if pedimentosExportLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Table size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_export_pedimentos']()}
</Button>
<Separator orientation="vertical" class="mx-1 h-8 hidden sm:block" />
<Button
size="sm"
onclick={handleAlta}
disabled={selectedDodaIds.length !== 1 || altaLoading}
title={altaVariant === 'pita' ? 'PITA' : 'DODA'}
>
{#if altaLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Send size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_generar']()}
</Button>
</div>
</div>
</div>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">DODA</h1>
<p class="text-muted-foreground">Catálogo de DODA</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" /> Actualizar
</Button>
{#if !isError && canCreate}
<Button class="h-9" onclick={handleNew}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Registro
</Button>
{/if}
</div>
</div>
<DodaExportExcelDialog bind:open={exportDialogOpen} companyId={companyStore.activeCompany?.id} />
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_doda.view' : error || ''}
onRetry={reloadData}
/>
{:else}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<Card.Title>Listado de DODA</Card.Title>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[200px_140px_160px_160px_auto] xl:items-center">
<Input placeholder="Folio" bind:value={filters.integration_number} oninput={handleSearch} class="h-9 bg-card" />
<Input placeholder="Patente" bind:value={filters.patent} oninput={handleSearch} class="h-9 bg-card" />
<select
bind:value={filters.status}
onchange={handleSearch}
class="h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">Estatus</option>
<option value="PENDIENTE">PENDIENTE</option>
<option value="GENERADO">GENERADO</option>
<option value="VALIDADO">VALIDADO</option>
<option value="ELIMINADO">ELIMINADO</option>
</select>
{#if progressDialogOpen}
<DodaProgressDialog
bind:open={progressDialogOpen}
taskId={currentTaskId}
dodaId={selectedDoda?.id}
variant={currentVariant}
onComplete={onAltaComplete}
onCancel={() => (progressDialogOpen = false)}
/>
<select
bind:value={filters.operation_type}
onchange={handleSearch}
class="h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">Operación</option>
<option value="I">I - Importación</option>
<option value="E">E - Exportación</option>
</select>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
Limpiar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="h-full overflow-hidden rounded-md border bg-background">
<InfiniteDataTable
data={allItems} {columns} {loading} {hasMore} {loadMore}
{selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {allItems.length} de {totalItems} registros
</div>
<div class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
{#if selectedItem}
Seleccionado: <span class="font-medium text-foreground">{selectedItem.integration_number || 'S/N'}</span>
{:else}
Selecciona un registro para ver acciones
{/if}
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
Actualizar
</Button>
{#if canEdit}
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
<Pencil size={16} class="mr-2" /> Editar
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedItem} class="text-destructive hover:bg-destructive/10">
<Trash2 size={16} class="mr-2" /> Eliminar
</Button>
{/if}
<Button variant="secondary" size="sm" onclick={handlePrint} disabled={!selectedItem}>
Imprimir
</Button>
</div>
</div>
</div>
</div>
{/if}
{#if $page.url.searchParams.get('doda_id')}
<DodaFormModal
dodaIdParam={$page.url.searchParams.get('doda_id')!}
onClose={() => {
const url = new URL($page.url);
url.searchParams.delete('doda_id');
goto(url.toString(), { replaceState: true });
reloadData();
}}
onCreatedNavigateTo={(newId) => {
const url = new URL($page.url);
url.searchParams.set('doda_id', String(newId));
goto(url.toString(), { replaceState: true });
}}
/>
{/if}
</div>

View File

@@ -0,0 +1,15 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
/**
* Mantiene enlaces antiguos /edit o /edit/:id: el formulario vive en la lista con ?doda_id=
*/
export const load: PageLoad = async ({ params }) => {
if (params.id) {
throw redirect(
303,
`/dashboard/general_catalogs/doda?doda_id=${encodeURIComponent(String(params.id))}`
);
}
throw redirect(303, '/dashboard/general_catalogs/doda?doda_id=new');
};

View File

@@ -21,10 +21,6 @@
import DeleteDialog from '$lib/components/dashboard/export/manifest/delete-dialog.svelte';
import DetailsDialog from '$lib/components/dashboard/export/manifest/details-dialog.svelte';
// Importaciones de Seguridad y UI de Errores
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
let { data }: { data: any } = $props();
// --- State ---
@@ -33,10 +29,7 @@
let currentPage = $state(data.page || 1);
let pageSize = $state(50);
let loading = $state(false);
// Manejo de estado de errores y HTTP status
let error = $state<string | null>(data.error || null);
let status = $state<number>(data.error ? 500 : 200);
let selectedId = $state<number | null>(null);
const selectedItem = $derived(
@@ -53,16 +46,7 @@
let showDelete = $state(false);
let showDetails = $state(false);
// 🛡️ Permisos
const canView = $derived(userHasPermission($currentUser, 'export_manifest.view'));
const canCreate = $derived(userHasPermission($currentUser, 'export_manifest.create'));
const canEdit = $derived(userHasPermission($currentUser, 'export_manifest.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'export_manifest.delete'));
const isError = $derived(!canView || status >= 400 || error);
// Pasamos canEdit y canDelete a las columnas por si tu DataTable tiene acciones por fila
const columns = createColumns({ canEdit, canDelete });
const columns = createColumns();
// --- Lifecycle ---
onMount(() => {
@@ -75,7 +59,6 @@
// --- Actions ---
async function reloadData() {
if (!canView) return; // Bloqueo de seguridad
if (!companyStore.activeCompany) return;
loading = true;
@@ -90,12 +73,10 @@
if (response.error) {
toast.error(response.error);
error = response.error;
status = response.status || 500;
return;
}
if (response.data) {
status = 200;
allItems = response.data.items || [];
totalItems = response.data.total || 0;
currentPage = 1;
@@ -103,15 +84,12 @@
} catch (e: any) {
console.error('Error reloading manifests:', e);
toast.error('Error al cargar datos');
error = 'Error de conexión';
status = 500;
} finally {
loading = false;
}
}
async function loadMore() {
if (!canView) return; // Bloqueo de seguridad
if (loading || allItems.length >= totalItems) return;
loading = true;
@@ -156,139 +134,134 @@
</script>
<div class="space-y-6 p-6 pb-24">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Manifiestos / Entry's</h1>
<p class="text-muted-foreground">Gestión de manifiestos de exportación</p>
</div>
{#if !isError && canCreate}
<Button onclick={() => goto('/dashboard/export/manifest/edit')}>
<Plus class="mr-2 h-4 w-4" />
Nuevo Manifiesto
</Button>
{/if}
<Button onclick={() => goto('/dashboard/export/manifest/edit')}>
<Plus class="mr-2 h-4 w-4" />
Nuevo Manifiesto
</Button>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: export_manifest.view' : error || ''}
onRetry={reloadData}
/>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-lg">
<Search class="h-5 w-5 text-muted-foreground" /> Filtros
</Card.Title>
<Card.Description>Busca manifiestos por número o descripción</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="filter-num">Número de Manifiesto</Label>
<Input
id="filter-num"
placeholder="Ej: MAN-2024-001"
bind:value={filters.manifest_number}
oninput={handleFilterChange}
/>
</div>
<div class="space-y-2">
<Label for="filter-search">Búsqueda General</Label>
<Input
id="filter-search"
placeholder="Descripción, transportista..."
bind:value={filters.search}
oninput={handleFilterChange}
/>
</div>
<!-- Filters Card -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2 text-lg">
<Search class="h-5 w-5 text-muted-foreground" /> Filtros
</Card.Title>
<Card.Description>Busca manifiestos por número o descripción</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="filter-num">Número de Manifiesto</Label>
<Input
id="filter-num"
placeholder="Ej: MAN-2024-001"
bind:value={filters.manifest_number}
oninput={handleFilterChange}
/>
</div>
</Card.Content>
</Card.Root>
<div class="space-y-2">
<Label for="filter-search">Búsqueda General</Label>
<Input
id="filter-search"
placeholder="Descripción, transportista..."
bind:value={filters.search}
oninput={handleFilterChange}
/>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-xl">Listado de Manifiestos</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
</Button>
</div>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
{columns}
{loading}
hasMore={allItems.length < totalItems}
{loadMore}
{selectedId}
onRowClick={handleRowClick}
/>
</Card.Content>
</Card.Root>
{/if}
<!-- List Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-xl">Listado de Manifiestos</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</Card.Description>
</div>
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
</Button>
</div>
</Card.Header>
<Card.Content>
<DataTable
data={allItems}
{columns}
{loading}
hasMore={allItems.length < totalItems}
{loadMore}
{selectedId}
onRowClick={handleRowClick}
/>
</Card.Content>
</Card.Root>
</div>
{#if !isError}
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-6 py-4">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground italic">
{#if selectedItem}
Seleccionado: <span class="font-mono font-bold text-foreground"
>{selectedItem.manifest_number}</span
>
{/if}
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() => (showDetails = true)}
disabled={!selectedItem}
<!-- Sticky Footer Actions -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-6 py-4">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground italic">
{#if selectedItem}
Seleccionado: <span class="font-mono font-bold text-foreground"
>{selectedItem.manifest_number}</span
>
<FileText class="mr-2 h-4 w-4" />
Ver Detalles
</Button>
{#if canEdit}
<Button
variant="outline"
size="sm"
onclick={() => selectedItem && goto(`/dashboard/export/manifest/edit/${selectedId}`)}
disabled={!selectedItem}
>
Editar
</Button>
{/if}
{#if canDelete}
<Button
variant="outline"
size="sm"
onclick={() => (showDelete = true)}
disabled={!selectedItem}
class="text-destructive hover:bg-destructive/5"
>
Borrar
</Button>
{/if}
</div>
{/if}
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={() => (showDetails = true)}
disabled={!selectedItem}
>
<FileText class="mr-2 h-4 w-4" />
Ver Detalles
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedItem && goto(`/dashboard/export/manifest/edit/${selectedId}`)}
disabled={!selectedItem}
>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={() => (showDelete = true)}
disabled={!selectedItem}
class="text-destructive hover:bg-destructive/5"
>
Borrar
</Button>
</div>
</div>
</div>
</div>
<DeleteDialog bind:open={showDelete} manifest={selectedItem} onSuccess={reloadData} />
<DetailsDialog bind:open={showDetails} manifest={selectedItem} />
{/if}
<!-- Dialogs -->
<DeleteDialog bind:open={showDelete} manifest={selectedItem} onSuccess={reloadData} />
<DetailsDialog bind:open={showDetails} manifest={selectedItem} />

View File

@@ -0,0 +1,751 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import {
createDoda,
updateDoda,
getDoda,
type DodaCreate
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import {
ArrowLeft,
Save,
RefreshCw,
FileText,
LayoutGrid,
Printer,
Trash2,
FolderSearch,
ShieldCheck,
LoaderCircle
} from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
import ChildDetailTable from '$lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte';
// Modales de Selección
import BrokerSelectorDialog from '$lib/components/dashboard/export/manifest/modals/broker-selector-dialog.svelte';
import CustomsSectionSelectorDialog from '$lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte';
import TransporterSelectorDialog from '$lib/components/dashboard/export/manifest/modals/transporter-selector-dialog.svelte';
// 1. Identificación reactiva
let id = $derived($page.params.id);
let isEdit = $derived(!!$page.params.id);
let title = $derived(isEdit ? 'Editar DODA' : 'Nuevo DODA');
let loading = $state(false);
let error = $state<string | null>(null);
let activeTab = $state('general');
// Estados de Modales
let showBrokerSelector = $state(false);
let showAduanaSelector = $state(false);
let showSectionSelector = $state(false);
let showTransporterSelector = $state(false);
// Atajos
useShortcuts(
'Formulario DODA',
obtenerAtajosFormularioDoda({
cambiarPestana: (pestana) => (activeTab = pestana),
manejarGuardar: handleSubmit,
manejarCerrar: () => goto('/dashboard/general_catalogs/doda')
})
);
function getEmptyForm(): DodaCreate & {
pedimentos_detail?: any[];
containers?: any[];
american_pedimentos?: any[];
uuid_carta_porte?: string;
} {
return {
integration_number: '',
doda_date: undefined,
doda_time: undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: 'I',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: 'PENDIENTE',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: 2, // 2 = DODA, 1 = PITA
unique_badge_number: '',
pedimentos_detail: [],
containers: [],
american_pedimentos: [],
uuid_carta_porte: ''
};
}
let formData = $state(getEmptyForm());
$effect(() => {
const currentId = $page.params.id;
const companyId = companyStore.activeCompany?.id;
if (currentId && companyId) {
loadDoda(Number(currentId));
} else if (!currentId) {
formData = getEmptyForm();
error = null;
}
});
async function loadDoda(dodaId: number) {
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
const data = await getDoda(dodaId, companyId);
if (data) {
formData = {
integration_number: data.integration_number || '',
doda_date: data.doda_date,
doda_time: data.doda_time,
dispatch_customs: data.dispatch_customs || '',
customs_sections: data.customs_sections || '',
patent: data.patent || '',
pedimentos: data.pedimentos || '',
caat: data.caat || '',
transport_identification: data.transport_identification || '',
fast_id: data.fast_id || '',
operation_type: data.operation_type || 'I',
selected: data.selected || false,
user_selected: data.user_selected || '',
last_user: data.last_user || '',
responsible: data.responsible || '',
carrier: data.carrier || '',
shipments: data.shipments || '',
pedimento_type: data.pedimento_type || '',
original_chain: data.original_chain || '',
serial_number: data.serial_number || '',
electronic_signature: data.electronic_signature || '',
transaction_number: data.transaction_number || '',
status: data.status || 'PENDIENTE',
linq_sat_qr: data.linq_sat_qr || '',
sat_certificate: data.sat_certificate || '',
sat_digital_seal: data.sat_digital_seal || '',
xml_doda_sent_path: data.xml_doda_sent_path || '',
xml_doda_response_path: data.xml_doda_response_path || '',
sat_original_chain: data.sat_original_chain || '',
customs_clearance: data.customs_clearance || 2,
unique_badge_number: data.unique_badge_number || '',
pedimentos_detail: data.pedimentos_detail || [],
containers: data.containers || [],
american_pedimentos: data.american_pedimentos || [],
uuid_carta_porte: '' // Simulated field
};
}
} catch (e) {
error = 'No se pudo cargar la información del DODA';
} finally {
loading = false;
}
}
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('Selecciona una compañía');
if (!formData.patent?.trim()) throw new Error('El Agente Aduanal (Patente) es requerido');
const payload: DodaCreate = {
...formData,
integration_number: formData.integration_number?.trim() || '',
doda_date: formData.doda_date || undefined,
doda_time: formData.doda_time || undefined,
customs_clearance: formData.customs_clearance || undefined
};
if (isEdit) {
await updateDoda(Number(id), payload, companyId);
} else {
await createDoda(payload, companyId);
}
goto('/dashboard/general_catalogs/doda');
} catch (e: any) {
error = e.message || 'Error al guardar';
} finally {
loading = false;
}
}
const pedimentosColumns = [
{ header: 'Doda Sysid', key: 'id' },
{ header: 'Línea Pedimento', key: 'pedimento_line' },
{ header: 'Patente Autorización', key: 'authorization_patent' },
{ header: 'Documento', key: 'document' },
{ header: 'Remesa', key: 'shipment' },
{ header: 'Cove', key: 'cove' },
{ header: 'UMC', key: 'umc' },
{ header: 'Importe Efectivo USD', key: 'effective_amount_usd' },
{ header: 'Importe Diferencia USD', key: 'difference_amount_usd' },
{ header: 'DTA NIU', key: 'dta_niu' },
{ header: 'Articulo 7', key: 'article_7', render: (v: any) => (v ? 'Sí' : 'No') }
];
const containersColumns = [
{ header: 'Contenedor', key: 'container_value' },
{ header: 'Percinto', key: 'seals' }
];
const americanPedimentosColumns = [
{ header: 'Tipo', key: 'american_pedimento_type' },
{ header: 'Pedido Americano', key: 'american_pedimento_value' }
];
// Handlers de Selección
function handleBrokerSelect(broker: any) {
formData.responsible = broker.broker_key || '';
formData.patent = broker.license || '';
}
function handleAduanaSelect(section: any) {
formData.dispatch_customs = section.customs_code || '';
}
function handleSectionSelect(section: any) {
formData.customs_sections = section.customs_code || '';
}
function handleTransporterSelect(transporter: any) {
formData.carrier = transporter.name || '';
}
function openOnEnterOrSpace(event: KeyboardEvent, action: () => void) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
action();
}
}
</script>
<div class="space-y-3 p-6 pb-48">
<!-- Header Estilo Facturas -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onclick={() => goto('/dashboard/general_catalogs/doda')}
>
<ArrowLeft size={20} />
</Button>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
</div>
<p class="text-muted-foreground">Catálogos Generales / Doda</p>
</div>
</div>
<Separator />
<Tabs.Root bind:value={activeTab} class="w-full">
<!-- Contenido Principal con Campos Superiores -->
<div class="space-y-6">
<!-- Fila Compacta de Datos Principales (Estilo InvoiceTopFields) con líneas blancas entre campos -->
<div class="grid grid-cols-12 items-end gap-0 pb-3">
<div class="col-span-3 space-y-1 border-r border-white/20 pr-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Responsable Agentes
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.responsible}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={14}
placeholder="Clave"
onclick={() => (showBrokerSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-8 w-8 shrink-0 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showAduanaSelector = true)}
>
Aduana
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.dispatch_customs}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showAduanaSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showAduanaSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showAduanaSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showSectionSelector = true)}
>
Sección
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.customs_sections}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showSectionSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showSectionSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showSectionSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 pl-3">
<Label class="text-xs leading-none text-muted-foreground">Operación</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v) => (formData.operation_type = v)}
>
<Select.Trigger class="h-8 border-none bg-background/5 text-sm font-medium shadow-none">
<span class="truncate"
>{formData.operation_type === 'E'
? 'E'
: formData.operation_type === 'I'
? 'I'
: '...'}</span
>
</Select.Trigger>
<Select.Content>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{#if error}
<div
class="animate-in fade-in slide-in-from-top-2 mb-6 flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-4 text-sm font-semibold text-destructive"
>
<span class="text-lg">⚠️</span>
{error}
</div>
{/if}
<Tabs.Content value="general" class="animate-in fade-in space-y-6 duration-300 outline-none">
<!-- Grid de Campos Generales Reorganizado -->
<div class="grid grid-cols-12 items-start gap-8">
<!-- Columna 1 (Izquierda): Stack Vertical Principal -->
<div class="col-span-3 space-y-4">
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showTransporterSelector = true)}
>
Transportista
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.carrier}
placeholder="Transportista"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showTransporterSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showTransporterSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showTransporterSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Identificación</Label
>
<Input
bind:value={formData.transport_identification}
placeholder="Identificación"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>No. de Integración</Label
>
<Input
bind:value={formData.integration_number}
placeholder="Integración"
class="h-9 bg-muted/20 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Transacción</Label
>
<Input
bind:value={formData.transaction_number}
placeholder="Transacción"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Fast ID</Label>
<Input
bind:value={formData.fast_id}
placeholder="Fast ID"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
<!-- Columna 4 (Alineada con Operación): Stack de Estatus/Patente -->
<div class="col-span-3 col-start-8 space-y-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">CAAT</Label>
<Input
bind:value={formData.caat}
placeholder="CAAT"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Patente <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.patent}
maxlength={4}
placeholder="Patente"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showBrokerSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Estatus</Label>
<Select.Root
type="single"
value={formData.status}
onValueChange={(v) => (formData.status = v)}
>
<Select.Trigger class="h-9 w-full text-sm font-medium shadow-sm">
{formData.status || 'Seleccionar...'}
</Select.Trigger>
<Select.Content>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Fila Horizontal: Despacho y Gafete (A la derecha de Fast ID) -->
<div class="col-span-12 grid grid-cols-12 items-end gap-8">
<div class="col-span-3">
<!-- Espacio vacío para alinear con la primera columna si es necesario -->
</div>
<!-- Despacho Aduanero (A la derecha de Fast ID en términos lógicos) -->
<div class="col-span-4">
<div
class="flex items-center justify-between rounded-xl border bg-muted/30 p-4 shadow-inner"
>
<Label class="text-xs font-semibold tracking-widest text-muted-foreground uppercase"
>Despacho Aduanero</Label
>
<RadioGroup.Root
value={formData.customs_clearance?.toString()}
onValueChange={(v) => (formData.customs_clearance = parseInt(v))}
class="flex gap-6"
>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="1" id="pita" class="h-4 w-4 border-primary" />
<Label for="pita" class="cursor-pointer text-xs font-medium uppercase"
>PITA</Label
>
</div>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="2" id="doda" class="h-4 w-4 border-primary" />
<Label for="doda" class="cursor-pointer text-xs font-medium uppercase"
>DODA</Label
>
</div>
</RadioGroup.Root>
</div>
</div>
<!-- Gafete Único (A la derecha de Despacho) -->
<div class="col-span-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Número de gafete único</Label
>
<Input
bind:value={formData.unique_badge_number}
placeholder="Gafete único"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
</div>
</div>
<!-- Tabla Principal -->
<div class="pt-4">
<ChildDetailTable
title="Detalle de Pedimentos"
columns={pedimentosColumns}
data={formData.pedimentos_detail || []}
class="border-border bg-card shadow-sm"
/>
</div>
<!-- Tablas Inferiores -->
<div class="grid grid-cols-1 gap-6 pt-4 lg:grid-cols-2">
<ChildDetailTable
title="Contenedores"
columns={containersColumns}
data={formData.containers || []}
/>
<ChildDetailTable
title="Pedimento Americano"
columns={americanPedimentosColumns}
data={formData.american_pedimentos || []}
/>
</div>
</Tabs.Content>
<Tabs.Content value="sellos" class="animate-in fade-in duration-300 outline-none">
<div class="max-w-4xl space-y-8 py-4">
<div class="space-y-1">
<h2 class="text-xl font-bold tracking-tight">Sellos y Firmas</h2>
<p class="text-xs font-semibold text-muted-foreground uppercase">
Validación electrónica ante el SAT
</p>
</div>
<div class="grid grid-cols-1 gap-8">
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Cadena original</Label
>
<Textarea
bind:value={formData.original_chain}
placeholder="Cadena Original..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número de certificado</Label
>
<Input
bind:value={formData.serial_number}
placeholder="Certificado"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica</Label
>
<Textarea
bind:value={formData.electronic_signature}
placeholder="Firma..."
class="min-h-[100px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-primary uppercase"
>UUID Carta Porte</Label
>
<Input
bind:value={formData.uuid_carta_porte}
placeholder="00000000-0000-0000-0000-000000000000"
class="w-full border-muted/60 font-mono text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número certificado SAT</Label
>
<Input
bind:value={formData.sat_certificate}
placeholder="Certificado SAT"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica SAT (Cadena Original SAT)</Label
>
<Textarea
bind:value={formData.sat_original_chain}
placeholder="Firma SAT..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
</div>
</div>
</Tabs.Content>
</div>
<!-- Footer fijo con Tabs.List y Botones de Acción -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-2">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<LayoutGrid size={16} class="mr-2" />
General
</Tabs.Trigger>
<Tabs.Trigger value="sellos" class="whitespace-nowrap">
<ShieldCheck size={16} class="mr-2" />
Sellos
</Tabs.Trigger>
</Tabs.List>
</div>
<div class="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onclick={() => goto('/dashboard/general_catalogs/doda')}
disabled={loading}
class="rounded px-8 font-medium"
>
Cancelar
</Button>
<Button
onclick={handleSubmit}
disabled={loading}
class="min-w-[200px] rounded font-bold uppercase shadow-sm"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Guardar Cambios' : 'Crear DODA'}
{/if}
</Button>
</div>
</div>
</div>
</Tabs.Root>
<!-- Diálogos de Selección de Catálogos -->
<BrokerSelectorDialog bind:open={showBrokerSelector} onSelect={handleBrokerSelect} />
<CustomsSectionSelectorDialog bind:open={showAduanaSelector} onSelect={handleAduanaSelect} />
<CustomsSectionSelectorDialog bind:open={showSectionSelector} onSelect={handleSectionSelect} />
<TransporterSelectorDialog
bind:open={showTransporterSelector}
onSelect={handleTransporterSelect}
/>
</div>

View File

@@ -1,248 +1,219 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/transportation/drivers/columns';
import CreateEditDialog from '$lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Pencil, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaChoferes } from '$lib/config/shortcuts/dashboard/general_catalogs/drivers/list';
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, Pencil, Trash2 } from 'lucide-svelte';
let { data } = $props();
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/drivers/columns';
let dialogOpen = $state(false);
let editingItem = $state<Driver | null>(null);
let error = $state<string | null>(data.error || null);
let status = $state<number>(data.status || 200);
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
import { companyStore } from '$lib/stores/company.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
// Permisos
const canView = $derived(userHasPermission($currentUser, 'cat_drivers.view'));
const canCreate = $derived(userHasPermission($currentUser, 'cat_drivers.create'));
const canEdit = $derived(userHasPermission($currentUser, 'cat_drivers.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'cat_drivers.delete'));
let data = $state<Driver[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let createDialogOpen = $state(false);
let editingItem = $state<Driver | null>(null);
let selectedIds = $state<(string | number)[]>([]);
const selectedDriver = $derived(
selectedIds.length === 1 ? data.find((d) => (d.transporter_key + '-' + d.line) === selectedIds[0]) : null
);
const hasSelection = $derived(selectedIds.length > 0);
const isError = $derived(!canView || status >= 400 || error);
let searchTransporterKey = $state('');
let searchDriverName = $state('');
// Atajos
useShortcuts(
'Lista Choferes',
obtenerAtajosListaChoferes({
manejarNuevo: () => {
if (!canCreate) return;
editingItem = null;
dialogOpen = true;
},
manejarActualizar: () => reloadData()
})
);
let filteredData = $derived(
data.filter((d) => {
const tk = searchTransporterKey.trim().toLowerCase();
const dn = searchDriverName.trim().toLowerCase();
if (tk && !(d.transporter_key ?? '').toLowerCase().includes(tk)) return false;
if (dn && !(d.driver_name ?? '').toLowerCase().includes(dn)) return false;
return true;
})
);
// Filtros
let searchKey = $state($page.url.searchParams.get('driver_key') || '');
let searchDesc = $state($page.url.searchParams.get('description') || '');
let timeout: ReturnType<typeof setTimeout>;
let hasMore = $derived(data.length < totalItems);
let allItems = $state<Driver[]>(data.items?.items || data.items || []);
let currentPage = $state(data.items?.page || 1);
let pageSize = $state(data.items?.page_size || 50);
let totalItems = $state(data.items?.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let selectedIds = $state<(string | number)[]>([]);
const selectedItem = $derived(
selectedIds.length === 1
? allItems.find((item) => `${item.transporter_key}-${item.line}` === String(selectedIds[0])) ?? null
: null
);
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize
});
if (response.data) {
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading drivers:', error);
} finally {
loading = false;
}
}
$effect(() => {
if (data.items) {
allItems = data.items.items || data.items || [];
currentPage = data.items.page || 1;
totalItems = data.items.total || 0;
pageSize = data.items.page_size || pageSize;
}
});
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading more drivers:', error);
} finally {
loading = false;
}
}
async function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(async () => {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await driversApi.list(companyStore.activeCompany.id, {
driver_key: searchKey || undefined,
description: searchDesc || undefined,
page: 1,
page_size: pageSize
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error aplicando filtros';
} finally {
loading = false;
}
function handleDialogSuccess() {
createDialogOpen = false;
editingItem = null;
loadData();
}
const url = new URL($page.url);
if (searchKey) url.searchParams.set('driver_key', searchKey);
else url.searchParams.delete('driver_key');
if (searchDesc) url.searchParams.set('description', searchDesc);
else url.searchParams.delete('description');
history.replaceState(history.state, '', url);
}, 500);
}
function handleRowDoubleClick(row: Driver) {
editingItem = row;
createDialogOpen = true;
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await driversApi.list(companyStore.activeCompany.id, {
driver_key: searchKey || undefined,
description: searchDesc || undefined,
page: currentPage + 1,
page_size: pageSize
});
const payload = response.data;
if (payload?.items) {
allItems = [...allItems, ...payload.items];
currentPage = payload.page || (currentPage + 1);
totalItems = payload.total;
}
} catch (err) {
error = 'Error cargando mas datos';
} finally {
loading = false;
}
}
function handleRowClick(row: Driver) {
const id = row.transporter_key + '-' + row.line;
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter((i) => i !== id);
} else {
selectedIds = [id];
}
}
async function reloadData() {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await driversApi.list(companyStore.activeCompany.id, {
driver_key: searchKey || undefined,
description: searchDesc || undefined,
page: 1,
page_size: pageSize
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error al recargar datos';
} finally {
loading = false;
}
}
function handleEditSelected() {
if (selectedDriver) {
editingItem = selectedDriver;
createDialogOpen = true;
}
}
function handleSuccess() {
dialogOpen = false;
editingItem = null;
selectedIds = [];
reloadData();
}
async function handleDeleteSelected() {
if (!selectedDriver) return;
if (
!confirm(
`¿Estás seguro de eliminar el conductor "${selectedDriver.driver_name || selectedDriver.transporter_key + '-' + selectedDriver.line}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
const columns = $derived(createColumns(handleSuccess, { canEdit, canDelete }));
if (!companyStore.activeCompany) return;
try {
const response = await driversApi.delete(
selectedDriver.transporter_key,
selectedDriver.line,
companyStore.activeCompany.id
);
if (response.error) {
alert(`❌ Error al eliminar:\n\n${response.error}`);
} else {
alert(`✅ Conductor eliminado correctamente`);
selectedIds = [];
loadData();
}
} catch (e) {
console.error('Error deleting:', e);
}
}
$effect(() => {
const _c = companyStore.activeCompany?.id;
loadData();
if (!createDialogOpen) {
editingItem = null;
}
});
useShortcuts('Conductores', [
...obtenerAtajosCatalogoSimple({
manejarNuevo: () => {
createDialogOpen = true;
},
manejarActualizar: loadData
}),
{
key: 'Ctrl+S',
description: 'Guardar',
action: () => {
if (!createDialogOpen) return;
document.dispatchEvent(new CustomEvent('save-form'));
}
}
]);
const columns = createColumns(loadData);
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Choferes</h1>
<p class="text-muted-foreground">Catálogo de Choferes</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" /> Actualizar
</Button>
{#if !isError && canCreate}
<Button class="h-9" onclick={() => { editingItem = null; dialogOpen = true; }}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Registro
</Button>
{/if}
</div>
</div>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Conductores</h1>
<p class="text-muted-foreground">Gestión del catálogo de conductores</p>
</div>
<div class="flex items-center gap-3"><Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}><Plus class="mr-2 h-4 w-4" />Nuevo Conductor</Button></div>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_drivers.view' : error || ''}
onRetry={reloadData}
/>
{:else}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Clave" bind:value={searchKey} oninput={handleSearch} class="h-9 w-40 bg-card lg:w-52" />
<Input placeholder="Descripción" bind:value={searchDesc} oninput={handleSearch} class="h-9 w-44 bg-card lg:w-64" />
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="h-full overflow-hidden rounded-md border bg-background">
<InfiniteDataTable
data={allItems} {columns} {loading} {hasMore} {loadMore}
{selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={(row) => {
const rowId = `${row.transporter_key}-${row.line}`;
selectedIds = selectedIds.includes(rowId) ? [] : [rowId];
}}
onRowDoubleClick={(row) => { if(canEdit) { editingItem = row; dialogOpen = true; } }}
getRowId={(row) => `${row.transporter_key}-${row.line}`}
/>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Conductores</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave transportista" class="h-9 w-44 bg-card lg:w-56" bind:value={searchTransporterKey} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchDriverName} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando conductores...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable data={filteredData} {columns} {loading} {hasMore} {loadMore} {selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)} onRowClick={handleRowClick} onRowDoubleClick={handleRowDoubleClick} getRowId={(row) => row.transporter_key + '-' + row.line} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {allItems.length} de {totalItems} registros
</div>
<div class="flex-none text-sm text-muted-foreground">Mostrando {filteredData.length} de {totalItems} registros</div>
<div class="h-20"></div>
<div class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => { editingItem = selectedItem; dialogOpen = true; }} disabled={!selectedItem}>
<Pencil size={16} class="mr-2" /> Editar
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={async () => {
if(!selectedItem || !companyStore.activeCompany) return;
if(confirm('¿Eliminar este registro?')) {
await driversApi.delete(selectedItem.transporter_key, selectedItem.line, companyStore.activeCompany.id);
selectedIds = [];
reloadData();
}
}} disabled={!selectedItem} class="text-destructive hover:bg-destructive/10">
<Trash2 size={16} class="mr-2" /> Eliminar
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Footer fijo con botones de acción -->
<div
id="drivers-list-footer"
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1}
>
<Pencil size={16} class="mr-2" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDeleteSelected}
disabled={selectedIds.length === 0}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
</Button>
</div>
</div>
</div>
<CreateEditDialog bind:open={dialogOpen} item={editingItem} onSuccess={handleSuccess} />
</div>
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>

View File

@@ -45,8 +45,8 @@ manejarActualizar: () => reloadData()
);
// Filtros
let searchCode = $state($page.url.searchParams.get('code') || '');
let searchDesc = $state($page.url.searchParams.get('description') || '');
let searchYear = $state($page.url.searchParams.get('year') || '');
let searchMonth = $state($page.url.searchParams.get('month') || '');
let timeout: ReturnType<typeof setTimeout>;
let allItems = $state<INPC[]>(data.items?.items || data.items || []);
@@ -80,8 +80,8 @@ loading = true;
error = null;
try {
const response = await inpcApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
year: searchYear || undefined,
month: searchMonth || undefined,
page: '1',
page_size: pageSize.toString()
});
@@ -97,10 +97,10 @@ loading = false;
}
const url = new URL($page.url);
if (searchCode) url.searchParams.set('code', searchCode);
else url.searchParams.delete('code');
if (searchDesc) url.searchParams.set('description', searchDesc);
else url.searchParams.delete('description');
if (searchYear) url.searchParams.set('year', searchYear);
else url.searchParams.delete('year');
if (searchMonth) url.searchParams.set('month', searchMonth);
else url.searchParams.delete('month');
history.replaceState(history.state, '', url);
}, 500);
}
@@ -110,8 +110,8 @@ if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await inpcApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
year: searchYear || undefined,
month: searchMonth || undefined,
page: (currentPage + 1).toString(),
page_size: pageSize.toString()
});
@@ -131,8 +131,8 @@ loading = true;
error = null;
try {
const response = await inpcApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
year: searchYear || undefined,
month: searchMonth || undefined,
page: '1',
page_size: pageSize.toString()
});
@@ -186,8 +186,8 @@ onRetry={reloadData}
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado de INPC</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Código" bind:value={searchCode} oninput={handleSearch} class="h-9 w-36 bg-card lg:w-44" />
<Input placeholder="Descripción" bind:value={searchDesc} oninput={handleSearch} class="h-9 w-44 bg-card lg:w-64" />
<Input placeholder="o" bind:value={searchYear} oninput={handleSearch} class="h-9 w-28 bg-card lg:w-36" />
<Input placeholder="Mes" bind:value={searchMonth} oninput={handleSearch} class="h-9 w-28 bg-card lg:w-36" />
</div>
</div>
</Card.Header>

View File

@@ -45,8 +45,8 @@ manejarActualizar: () => reloadData()
);
// Filtros
let searchCode = $state($page.url.searchParams.get('code') || '');
let searchDesc = $state($page.url.searchParams.get('description') || '');
let searchSeal = $state($page.url.searchParams.get('seal') || '');
let timeout: ReturnType<typeof setTimeout>;
let allItems = $state<Seal[]>(data.items?.items || data.items || []);
@@ -80,8 +80,8 @@ loading = true;
error = null;
try {
const response = await sealsApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
seal: searchSeal || undefined,
page: '1',
page_size: pageSize.toString()
});
@@ -97,10 +97,8 @@ loading = false;
}
const url = new URL($page.url);
if (searchCode) url.searchParams.set('code', searchCode);
else url.searchParams.delete('code');
if (searchDesc) url.searchParams.set('description', searchDesc);
else url.searchParams.delete('description');
if (searchSeal) url.searchParams.set('seal', searchSeal);
else url.searchParams.delete('seal');
history.replaceState(history.state, '', url);
}, 500);
}
@@ -110,8 +108,8 @@ if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await sealsApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
seal: searchSeal || undefined,
page: (currentPage + 1).toString(),
page_size: pageSize.toString()
});
@@ -131,8 +129,8 @@ loading = true;
error = null;
try {
const response = await sealsApi.list(companyStore.activeCompany.id, {
code: searchCode || undefined,
description: searchDesc || undefined,
seal: searchSeal || undefined,
page: '1',
page_size: pageSize.toString()
});
@@ -186,8 +184,7 @@ onRetry={reloadData}
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado de Sellos</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Código" bind:value={searchCode} oninput={handleSearch} class="h-9 w-36 bg-card lg:w-44" />
<Input placeholder="Descripción" bind:value={searchDesc} oninput={handleSearch} class="h-9 w-44 bg-card lg:w-64" />
<Input placeholder="Filtrar por sello" bind:value={searchSeal} oninput={handleSearch} class="h-9 w-56 bg-card lg:w-72" />
</div>
</div>
</Card.Header>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import TariffFractionList from '$lib/components/dashboard/goods/fractions/TariffFractionList.svelte';
import * as m from '$lib/paraglide/messages.js';
</script>
<TariffFractionList title={m['sidebar.fractions.american']()} catalog="american" readOnly={false} />

View File

@@ -1,212 +1,238 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, Pencil, Trash2 } from 'lucide-svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/trailers/columns';
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, Pencil, Trash2 } from 'lucide-svelte';
// ESTADO
let data = $state<Trailer[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let error = $state<string | null>(null);
let status = $state<number>(200);
// Importar componentes de la librería
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/trailers/columns';
let createDialogOpen = $state(false);
let editingItem = $state<Trailer | null>(null);
let selectedIds = $state<(string | number)[]>([]);
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
// Filtros
let searchKey = $state($page.url.searchParams.get('trailer_key') || '');
// --- ESTADO ---
let data = $state<Trailer[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let createDialogOpen = $state(false);
let editingItem = $state<Trailer | null>(null);
let selectedIds = $state<(string | number)[]>([]);
const selectedTrailer = $derived(
selectedIds.length === 1 ? data.find((t) => t.trailer_number === selectedIds[0]) : null
);
const hasSelection = $derived(selectedIds.length > 0);
// Filtros
let searchNumber = $state($page.url.searchParams.get('trailer_number') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
let searchTimeout: NodeJS.Timeout;
// PERMISOS
const canView = $derived(userHasPermission($currentUser, 'trailers.view'));
const canCreate = $derived(userHasPermission($currentUser, 'trailers.create'));
const canEdit = $derived(userHasPermission($currentUser, 'trailers.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'trailers.delete'));
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
trailer_number: searchNumber,
plate_number: searchPlate
});
const isError = $derived(!canView || status >= 400 || error);
const hasMore = $derived(data.length < totalItems);
const selectedItem = $derived(
selectedIds.length === 1 ? data.find((v) => String(v.trailer_key) === String(selectedIds[0])) : null
);
if (response.data) {
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading trailers:', error);
} finally {
loading = false;
}
}
// COLUMNAS
const columns = $derived(createColumns(loadData, { canEdit, canDelete }));
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
trailer_number: searchNumber,
plate_number: searchPlate
});
$effect(() => {
if (companyStore.activeCompany?.id) {
loadData();
}
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading more trailers:', error);
} finally {
loading = false;
}
}
async function loadData() {
if (!companyStore.activeCompany || !canView) return;
loading = true;
error = null;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
trailer_key: searchKey,
plate_number: searchPlate,
});
function handleDialogSuccess() {
createDialogOpen = false;
editingItem = null;
loadData();
}
if (response.error) {
error = response.error;
status = response.status || 500;
} else if (response.data) {
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
status = 200;
}
} catch (e: any) {
error = e.message || 'Error al cargar datos';
status = e.status || 500;
} finally {
loading = false;
}
}
function handleRowDoubleClick(row: Trailer) {
editingItem = row;
createDialogOpen = true;
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
trailer_key: searchKey,
plate_number: searchPlate,
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} finally {
loading = false;
}
}
function handleRowClick(row: Trailer) {
const id = row.trailer_number;
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter((i) => i !== id);
} else {
selectedIds = [id];
}
}
function handleDialogSuccess() {
createDialogOpen = false;
editingItem = null;
loadData();
}
function handleEditSelected() {
if (selectedTrailer) {
editingItem = selectedTrailer;
createDialogOpen = true;
}
}
async function handleDeleteSelected() {
if (!selectedTrailer) return;
if (
!confirm(
`¿Estás seguro de eliminar el trailer "${selectedTrailer.trailer_number}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) return;
try {
const response = await trailersApi.delete(
selectedTrailer.trailer_number,
companyStore.activeCompany.id
);
if (response.error) {
alert(`❌ Error al eliminar:\n\n${response.error}`);
} else {
alert(`✅ Trailer eliminado correctamente`);
selectedIds = [];
loadData();
}
} catch (e) {
console.error('Error deleting:', e);
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
if (searchNumber) url.searchParams.set('trailer_number', searchNumber);
else url.searchParams.delete('trailer_number');
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
if (searchKey) url.searchParams.set('trailer_key', searchKey);
else url.searchParams.delete('trailer_key');
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
history.replaceState(history.state, '', url);
loadData();
}, 500);
}
useShortcuts('Remolques (Transporte)', obtenerAtajosCatalogoSimple({
manejarNuevo: () => { if(canCreate) { editingItem = null; createDialogOpen = true; } },
manejarActualizar: loadData
}));
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
if (!createDialogOpen) {
editingItem = null;
}
});
useShortcuts('Trailers', [
...obtenerAtajosCatalogoSimple({
manejarNuevo: () => {
createDialogOpen = true;
},
manejarActualizar: loadData
}),
{
key: 'Ctrl+S',
description: 'Guardar',
action: () => {
if (!createDialogOpen) return;
document.dispatchEvent(new CustomEvent('save-form'));
}
}
]);
const columns = createColumns(loadData);
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Remolques (Transporte)</h1>
<p class="text-muted-foreground">Gestión del catálogo de remolques y plataformas</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={loadData}>
<RefreshCw class="mr-2 h-4 w-4" /> Actualizar
</Button>
{#if canCreate && !isError}
<Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Registro
</Button>
{/if}
</div>
</div>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Trailers</h1>
<p class="text-muted-foreground">Gestión del catálogo de trailers de la compañía</p>
</div>
<div class="flex items-center gap-3"><Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}><Plus class="mr-2 h-4 w-4" />Nuevo Trailer</Button></div>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_trailers.view' : error || ''}
onRetry={loadData}
/>
{:else}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Clave" class="h-9 w-40 bg-card lg:w-52" bind:value={searchKey} oninput={handleSearch} />
<Input placeholder="Placas" class="h-9 w-40 bg-card lg:w-52" bind:value={searchPlate} oninput={handleSearch} />
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="h-full overflow-hidden rounded-md border bg-background">
<InfiniteDataTable
{data} {columns} {loading} {hasMore} {loadMore}
{selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={(row) => selectedIds = selectedIds.includes(row.trailer_key) ? [] : [row.trailer_key]}
onRowDoubleClick={(row) => { if(canEdit) { editingItem = row; createDialogOpen = true; } }}
getRowId={(row) => row.trailer_key}
/>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Trailers</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Número" class="h-9 w-40 bg-card lg:w-52" bind:value={searchNumber} oninput={handleSearch} /><Input placeholder="Placas" class="h-9 w-40 bg-card lg:w-52" bind:value={searchPlate} oninput={handleSearch} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando trailers...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} {selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)} onRowClick={handleRowClick} onRowDoubleClick={handleRowDoubleClick} getRowId={(row) => row.trailer_number} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {data.length} de {totalItems} registros
</div>
<div class="flex-none text-sm text-muted-foreground">Mostrando {data.length} de {totalItems} registros</div>
<div class="h-20"></div>
<div class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => { editingItem = selectedItem; createDialogOpen = true; }} disabled={!selectedItem}>
<Pencil size={16} class="mr-2" /> Editar
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={async () => {
if(!selectedItem || !companyStore.activeCompany) return;
if(confirm(`¿Eliminar Registro ${selectedItem.trailer_key}?`)) {
await trailersApi.delete(selectedItem.trailer_key, companyStore.activeCompany.id);
selectedIds = [];
loadData();
}
}} disabled={!selectedItem} class="text-destructive hover:bg-destructive/10">
<Trash2 size={16} class="mr-2" /> Eliminar
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Footer fijo con botones de acción -->
<div
id="trailers-list-footer"
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1}
>
<Pencil size={16} class="mr-2" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDeleteSelected}
disabled={selectedIds.length === 0}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
</Button>
</div>
</div>
</div>
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>

View File

@@ -1,222 +1,238 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, Pencil, Trash2 } from 'lucide-svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/transporters/transporter-columns';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw, Pencil, Trash2 } from 'lucide-svelte';
// ESTADO
let data = $state<Transporter[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let error = $state<string | null>(null);
let status = $state<number>(200);
// Importar componentes de la librería
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/transporters/transporter-columns';
let createDialogOpen = $state(false);
let editingItem = $state<Transporter | null>(null);
let selectedIds = $state<(string | number)[]>([]);
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
// Filtros
let searchKey = $state($page.url.searchParams.get('transporter_key') || '');
// --- ESTADO ---
let data = $state<Transporter[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let createDialogOpen = $state(false);
let editingItem = $state<Transporter | null>(null);
let selectedIds = $state<(string | number)[]>([]);
const selectedTransporter = $derived(
selectedIds.length === 1 ? data.find((t) => t.transporter_key === selectedIds[0]) : null
);
const hasSelection = $derived(selectedIds.length > 0);
// Filtros
let searchKey = $state($page.url.searchParams.get('transporter_key') || '');
let searchName = $state($page.url.searchParams.get('name') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
let searchTimeout: NodeJS.Timeout;
// PERMISOS
const canView = $derived(userHasPermission($currentUser, 'transporters.view'));
const canCreate = $derived(userHasPermission($currentUser, 'transporters.create'));
const canEdit = $derived(userHasPermission($currentUser, 'transporters.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'transporters.delete'));
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
transporter_key: searchKey,
name: searchName
});
const isError = $derived(!canView || status >= 400 || error);
const hasMore = $derived(data.length < totalItems);
const selectedItem = $derived(
selectedIds.length === 1 ? data.find((v) => String(v.transporter_key) === String(selectedIds[0])) : null
);
if (response.data) {
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading transporters:', error);
} finally {
loading = false;
}
}
// COLUMNAS
const columns = $derived(createColumns(loadData, { canEdit, canDelete }));
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
transporter_key: searchKey,
name: searchName
});
$effect(() => {
if (companyStore.activeCompany?.id) {
loadData();
}
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} catch (error) {
console.error('Error loading more transporters:', error);
} finally {
loading = false;
}
}
function handleDialogSuccess() {
createDialogOpen = false;
editingItem = null;
loadData();
}
async function loadData() {
if (!companyStore.activeCompany || !canView) return;
loading = true;
error = null;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
transporter_key: searchKey,
name: searchName,
});
function handleRowDoubleClick(row: Transporter) {
editingItem = row;
createDialogOpen = true;
}
if (response.error) {
error = response.error;
status = response.status || 500;
} else if (response.data) {
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
status = 200;
}
} catch (e: any) {
error = e.message || 'Error al cargar datos';
status = e.status || 500;
} finally {
loading = false;
}
}
function handleRowClick(row: Transporter) {
const id = row.transporter_key;
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter((i) => i !== id);
} else {
selectedIds = [id]; // Por ahora selección única para simplificar
}
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
transporter_key: searchKey,
name: searchName,
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} finally {
loading = false;
}
}
function handleEditSelected() {
if (selectedTransporter) {
editingItem = selectedTransporter;
createDialogOpen = true;
}
}
function handleDialogSuccess() {
createDialogOpen = false;
editingItem = null;
loadData();
}
async function handleDeleteSelected() {
if (!selectedTransporter) return;
if (
!confirm(
`¿Estás seguro de eliminar el transportista "${selectedTransporter.transporter_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
if (searchKey) url.searchParams.set('transporter_key', searchKey);
if (!companyStore.activeCompany) return;
try {
const response = await transportersApi.delete(
selectedTransporter.transporter_key,
companyStore.activeCompany.id
);
if (response.error) {
alert(`❌ Error al eliminar:\n\n${response.error}`);
} else {
alert(`✅ Transportista eliminado correctamente`);
selectedIds = [];
loadData();
}
} catch (e) {
console.error('Error deleting:', e);
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
if (searchKey) url.searchParams.set('transporter_key', searchKey);
else url.searchParams.delete('transporter_key');
if (searchName) url.searchParams.set('name', searchName);
else url.searchParams.delete('name');
history.replaceState(history.state, '', url);
loadData();
}, 500);
}
useShortcuts('Transportistas', obtenerAtajosCatalogoSimple({
manejarNuevo: () => { if(canCreate) { editingItem = null; createDialogOpen = true; } },
manejarActualizar: loadData
}));
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
if (!createDialogOpen) {
editingItem = null;
}
});
useShortcuts('Transportistas', [
...obtenerAtajosCatalogoSimple({
manejarNuevo: () => {
createDialogOpen = true;
},
manejarActualizar: loadData
}),
{
key: 'Ctrl+S',
description: 'Guardar',
action: () => {
if (!createDialogOpen) return;
document.dispatchEvent(new CustomEvent('save-form'));
}
}
]);
const columns = createColumns(loadData);
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Transportistas</h1>
<p class="text-muted-foreground">Gestión del catálogo de empresas transportistas</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={loadData}>
<RefreshCw class="mr-2 h-4 w-4" /> Actualizar
</Button>
{#if canCreate && !isError}
<Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Registro
</Button>
{/if}
</div>
</div>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Transportistas</h1>
<p class="text-muted-foreground">Gestión del catálogo de líneas transportistas</p>
</div>
<div class="flex items-center gap-3"><Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}><Plus class="mr-2 h-4 w-4" />Nuevo Transportista</Button></div>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_transporters.view' : error || ''}
onRetry={loadData}
/>
{:else}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Clave" class="h-9 w-40 bg-card lg:w-52" bind:value={searchKey} oninput={handleSearch} />
<Input placeholder="Nombre" class="h-9 w-40 bg-card lg:w-52" bind:value={searchName} oninput={handleSearch} />
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="h-full overflow-hidden rounded-md border bg-background">
<InfiniteDataTable
{data} {columns} {loading} {hasMore} {loadMore}
{selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={(row) => selectedIds = selectedIds.includes(row.transporter_key) ? [] : [row.transporter_key]}
onRowDoubleClick={(row) => { if(canEdit) { editingItem = row; createDialogOpen = true; } }}
getRowId={(row) => row.transporter_key}
/>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Transportistas</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave" class="h-9 w-40 bg-card lg:w-52" bind:value={searchKey} oninput={handleSearch} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchName} oninput={handleSearch} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando transportistas...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} {selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)} onRowClick={handleRowClick} onRowDoubleClick={handleRowDoubleClick} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {data.length} de {totalItems} registros
<div class="flex-none text-sm text-muted-foreground">Mostrando {data.length} de {totalItems} registros</div>
<div class="h-20"></div>
<!-- Footer fijo con botones de acción -->
<div
id="transporters-list-footer"
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1}
>
<Pencil size={16} class="mr-2" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDeleteSelected}
disabled={selectedIds.length === 0}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
</Button>
</div>
</div>
</div>
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>
<!-- 1. Primero borramos las relaciones de usuarios con tenants
DELETE FROM user_tenant;
2. Luego borramos las compañías (que dependen de los tenants)
DELETE FROM company;
3. Finalmente borramos los tenants (inquilinos)
DELETE FROM tenant;
(Opcional) Si tienes una tabla de usuarios locales, también límpiala para evitar duplicados
DELETE FROM Footer Actions -->
<div class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if canEdit}
<Button variant="outline" size="sm" onclick={() => { editingItem = selectedItem; createDialogOpen = true; }} disabled={!selectedItem}>
<Pencil size={16} class="mr-2" /> Editar
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={async () => {
if(!selectedItem || !companyStore.activeCompany) return;
if(confirm(`¿Eliminar Registro ${selectedItem.transporter_key}?`)) {
await transportersApi.delete(selectedItem.transporter_key, companyStore.activeCompany.id);
selectedIds = [];
loadData();
}
}} disabled={!selectedItem} class="text-destructive hover:bg-destructive/10">
<Trash2 size={16} class="mr-2" /> Eliminar
</Button>
{/if}
</div>
</div>
</div>
{/if}
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>

View File

@@ -16,8 +16,6 @@
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
// --- ESTADO ---
let data = $state<Vehicle[]>([]);
@@ -27,19 +25,8 @@
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let createDialogOpen = $state(false);
let error = $state<string | null>(null);
let status = $state<number>(200);
let editingItem = $state<Vehicle | null>(null);
let selectedIds = $state<(string | number)[]>([]);
// Permisos
const canView = $derived(userHasPermission($currentUser, 'vehicles.view'));
const canCreate = $derived(userHasPermission($currentUser, 'vehicles.create'));
const canEdit = $derived(userHasPermission($currentUser, 'vehicles.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'vehicles.delete'));
const isError = $derived(!canView || status >= 400 || error);
const selectedVehicle = $derived(
selectedIds.length === 1 ? data.find((v) => v.vehicle_key === selectedIds[0]) : null
);
@@ -48,15 +35,12 @@
// Filtros
let searchKey = $state($page.url.searchParams.get('vehicle_key') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
if (!canView) return;
loading = true;
error = null;
try {
const response = await vehiclesApi.list(companyStore.activeCompany.id, {
page: 1,
@@ -65,22 +49,13 @@
plate_number: searchPlate
});
if (response.error) {
error = response.error;
status = response.status || 500;
return;
}
if (response.data) {
status = 200;
data = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e: any) {
console.error('Error loading vehicles:', e);
error = e.message || 'Error al cargar los datos';
status = e.status || 500;
} catch (error) {
console.error('Error loading vehicles:', error);
} finally {
loading = false;
}
@@ -102,8 +77,8 @@
currentPage += 1;
totalItems = response.data.total;
}
} catch (e) {
console.error('Error loading more vehicles:', e);
} catch (error) {
console.error('Error loading more vehicles:', error);
} finally {
loading = false;
}
@@ -177,17 +152,14 @@
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
history.replaceState(history.state, '', url);
loadData();
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const id = companyStore.activeCompany?.id;
if (id) {
loadData();
}
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
if (!createDialogOpen) {
editingItem = null;
@@ -197,7 +169,7 @@
useShortcuts('Vehículos (transporte)', [
...obtenerAtajosCatalogoSimple({
manejarNuevo: () => {
if (canCreate) createDialogOpen = true;
createDialogOpen = true;
},
manejarActualizar: loadData
}),
@@ -211,12 +183,10 @@
}
]);
const columns = $derived(createColumns(loadData, { canEdit, canDelete }));
const columns = createColumns(loadData);
</script>
<div
class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Vehículos (Transporte)</h1>
@@ -224,153 +194,47 @@
Gestión del catálogo de camiones y vehículos de transporte
</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={loadData}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
{#if !isError && canCreate}
<div class="flex items-center gap-3"><Button class="h-9" onclick={() => { editingItem = null; createDialogOpen = true; }}><Plus class="mr-2 h-4 w-4" />Nuevo Vehículo</Button></div>
</div>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Vehículos</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave" class="h-9 w-40 bg-card lg:w-52" bind:value={searchKey} oninput={handleSearch} /><Input placeholder="Placas" class="h-9 w-40 bg-card lg:w-52" bind:value={searchPlate} oninput={handleSearch} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando vehículos...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} {selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)} onRowClick={handleRowClick} onRowDoubleClick={handleRowDoubleClick} getRowId={(row) => row.vehicle_key} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">Mostrando {data.length} de {totalItems} registros</div>
<div class="h-20"></div>
<!-- Footer fijo con botones de acción -->
<div
id="vehicles-list-footer"
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
<Button
class="h-9"
onclick={() => {
editingItem = null;
createDialogOpen = true;
}}
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1}
>
<Plus class="mr-2 h-4 w-4" />
Nuevo Vehículo
<Pencil size={16} class="mr-2" />
Editar
</Button>
{/if}
<Button
variant="outline"
size="sm"
onclick={handleDeleteSelected}
disabled={selectedIds.length === 0}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
</Button>
</div>
</div>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_vehicles.view' : error || ''}
onRetry={loadData}
/>
{:else}
{#if error}
<div
class="flex-none rounded-md border border-destructive px-4 py-3 text-sm text-destructive"
>
{error}
</div>
{/if}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado de Vehículos</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input
placeholder="Clave"
class="h-9 w-40 bg-card lg:w-52"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Placas"
class="h-9 w-40 bg-card lg:w-52"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando vehículos...
</div>
{:else}
<div
class="flex h-full min-h-0 flex-col overflow-hidden rounded-md border bg-background"
>
<InfiniteDataTable
{data}
{columns}
{loading}
{hasMore}
{loadMore}
{selectedIds}
onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
getRowId={(row) => row.vehicle_key}
/>
</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-wrap items-center justify-end gap-3">
<div class="flex flex-wrap items-center gap-2">
<Input
placeholder="Clave"
class="h-9 w-40 bg-card lg:w-52"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Placas"
class="h-9 w-40 bg-card lg:w-52"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="flex h-full min-h-0 flex-col overflow-hidden rounded-md border bg-background">
<InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} />
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {data.length} de {totalItems} registros
</div>
<div class="h-20"></div>
<!-- Footer fijo con botones de acción -->
<div
id="vehicles-list-footer"
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1 || !canEdit}
>
<Pencil size={16} class="mr-2" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDeleteSelected}
disabled={selectedIds.length === 0 || !canDelete}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
</Button>
</div>
</div>
</div>
<CreateEditDialog
bind:open={createDialogOpen}
item={editingItem}
onSuccess={handleDialogSuccess}
/>
{/if}
<CreateEditDialog bind:open={createDialogOpen} item={editingItem} onSuccess={handleDialogSuccess} />
</div>

View File

@@ -61,28 +61,24 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
const invoiceNumber = url.searchParams.get('invoice_number');
const projectNumber = url.searchParams.get('project_number');
const year = url.searchParams.get('year');
const yearFrom = url.searchParams.get('year_from');
const yearTo = url.searchParams.get('year_to');
if (invoiceType) params.append('invoice_type', invoiceType);
if (operationType) params.append('operation_type', operationType);
if (invoiceNumber) params.append('invoice_number', invoiceNumber);
if (projectNumber) params.append('project_number', projectNumber);
if (year) params.append('year', year);
if (yearFrom) params.append('year_from', yearFrom);
if (yearTo) params.append('year_to', yearTo);
// Cargar facturas e invoice types en paralelo
const [response, invoiceTypesResponse] = await Promise.all([
authenticatedFetch(
`v1/a76/invoices/?${params.toString()}`,
`v1/a76/invoices?${params.toString()}`,
{},
cookies,
fetch,
'/auth/login'
),
authenticatedFetch(
`v1/public/reference_data/invoice-types/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/invoice-types?page=1&page_size=100',
{},
cookies,
fetch,
@@ -96,8 +92,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
total: 0,
page: 1,
page_size: 50,
error: response.status === 403 ? 'Acceso denegado' : 'Error al cargar facturas',
status: response.status,
error: 'Error al cargar facturas',
companies: parentData.companies || [],
currentCompanyId: companyId,
filters: {
@@ -123,9 +118,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
operation_type: operationType,
invoice_number: invoiceNumber,
project_number: projectNumber,
year: year,
year_from: yearFrom,
year_to: yearTo
year: year
}
};
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -96,11 +96,6 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
if (editionResponse.status === 404) {
throw error(404, 'Factura no encontrada');
}
if (editionResponse.status === 403) {
// Si no tiene permiso de edición (común después de crear si solo tiene permiso de creación)
// lo mandamos de vuelta al listado
throw redirect(303, '/dashboard/invoices');
}
throw error(editionResponse.status, 'Error al cargar la factura');
}
@@ -157,11 +152,8 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
};
} catch (err: any) {
// IMPORTANT: Re-throw redirects and SvelteKit errors so they are handled by the framework
if (err && err.status && (err.location || err.body)) {
throw err;
}
console.error('Error in load function:', err);
if (err && err.status === 404) throw err; // Propagate 404
throw error(500, 'Error interno al cargar la página');
}
};

View File

@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
// Reuse the same calls as in edit/[id] to ensure we have data for the dropdowns
const invoiceTypesPromise = authenticatedFetch(
`v1/public/reference_data/invoice-types/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/invoice-types/?page=1&page_size=100',
{},
cookies,
fetch
@@ -49,14 +49,14 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
);
const currencyTypesPromise = authenticatedFetch(
`v1/public/reference_data/currency-types/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/currency-types/?page=1&page_size=100',
{},
cookies,
fetch
);
const transportTypesPromise = authenticatedFetch(
`v1/public/reference_data/transport-types/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/transport-types/?page=1&page_size=100',
{},
cookies,
fetch
@@ -91,35 +91,35 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
);
const incotermsPromise = authenticatedFetch(
`v1/public/reference_data/incoterms/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/incoterms/?page=1&page_size=100',
{},
cookies,
fetch
);
const customsSectionsPromise = authenticatedFetch(
`v1/public/reference_data/customs-sections/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/customs-sections/?page=1&page_size=100',
{},
cookies,
fetch
);
const codePedimentoRegimensPromise = authenticatedFetch(
`v1/public/reference_data/code-pedimento-regimens/?page=1&page_size=1000&company_id=${companyId}`,
'v1/public/reference_data/code-pedimento-regimens/?page=1&page_size=1000',
{},
cookies,
fetch
);
const transportModesPromise = authenticatedFetch(
`v1/public/reference_data/transport-modes/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/transport-modes/?page=1&page_size=100',
{},
cookies,
fetch
);
const valuationMethodsPromise = authenticatedFetch(
`v1/public/reference_data/valuation-methods/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/valuation-methods/?page=1&page_size=100',
{},
cookies,
fetch
@@ -140,7 +140,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
);
const enclosuresPromise = authenticatedFetch(
`v1/public/reference_data/customs-warehouses/?page=1&page_size=100&company_id=${companyId}`,
'v1/public/reference_data/customs-warehouses/?page=1&page_size=100',
{},
cookies,
fetch

View File

@@ -9,23 +9,13 @@
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import { Separator } from '$lib/components/ui/separator';
import {
LoaderCircle,
Save,
FileText,
Eye,
DollarSign,
Truck,
Package,
ArrowLeft,
CircleAlert
import {
LoaderCircle, Save, FileText, Eye, DollarSign,
Truck, Package, ArrowLeft
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import * as Alert from '$lib/components/ui/alert';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
// Import Form Components
import GeneralTabForm from '$lib/components/dashboard/invoices/edit/general-tab-form.svelte';
@@ -42,9 +32,6 @@
// Props
let { data } = $props();
const canViewSettings = $derived(userHasPermission($currentUser, 'settings_general.view'));
const canEditSettings = $derived(userHasPermission($currentUser, 'settings_general.edit'));
// State
let selectedInvoiceType = $state<string>('');
let selectedOperationType = $state<string>('');
@@ -108,9 +95,6 @@
});
async function loadSettings() {
if (!userHasPermission($currentUser, 'settings_general.view')) {
return;
}
const cid = companyStore?.activeCompany?.id;
if (!selectedInvoiceType || !selectedOperationType || !cid) {
return;
@@ -452,10 +436,6 @@
let operationTypeNumeric = $derived(selectedOperationType === 'exp' ? 1 : 2);
async function handleSaveSettings() {
if (!userHasPermission($currentUser, 'settings_general.edit')) {
toast.error('No tienes permiso para guardar. Se requiere settings_general.edit.');
return;
}
if (!selectedInvoiceType || !selectedOperationType || !companyStore?.activeCompany?.id) {
toast.error('Por favor selecciona tipo de factura y operación');
return;
@@ -520,9 +500,6 @@
// NO MORE reactive loading effects. Manual triggers only to prevent loops.
// Initial mount load.
$effect(() => {
if (!userHasPermission($currentUser, 'settings_general.view')) {
return;
}
const cid = companyStore?.activeCompany?.id;
if (browser && cid && selectedOperationType && selectedInvoiceType && !lastLoadedKey && !isLoading) {
untrack(() => {
@@ -544,13 +521,6 @@
}
</script>
{#if !canViewSettings}
<ErrorState
status={403}
error="Permission denied: settings_general.view"
onBack={() => goto('/dashboard/invoices')}
/>
{:else}
<Tabs.Root bind:value={activeTab}>
<div class="space-y-4">
<!-- Header -->
@@ -582,18 +552,6 @@
<Separator />
{#if !canEditSettings}
<Alert.Root variant="default" class="mb-4 border-amber-500/40 bg-amber-500/5">
<CircleAlert class="h-4 w-4 text-amber-600" />
<Alert.Title class="text-amber-800 dark:text-amber-200">Solo lectura</Alert.Title>
<Alert.Description class="text-amber-900/80 dark:text-amber-100/90">
Puedes revisar la configuración, pero no tienes permiso para guardar cambios
(<span class="font-mono text-xs">settings_general.edit</span>). Los botones Guardar y Restablecer
están deshabilitados.
</Alert.Description>
</Alert.Root>
{/if}
<div class="pb-48">
<Card.Root class="mb-8 border-dashed bg-muted/30">
<Card.Header class="py-4">
@@ -859,17 +817,10 @@
{/if}
<div class="flex justify-end gap-3">
<Button
variant="outline"
onclick={resetForms}
disabled={isSaving || !selectedInvoiceType || !canEditSettings}
>
<Button variant="outline" onclick={resetForms} disabled={isSaving || !selectedInvoiceType}>
Restablecer
</Button>
<Button
onclick={handleSaveSettings}
disabled={isSaving || !selectedInvoiceType || !canEditSettings}
>
<Button onclick={handleSaveSettings} disabled={isSaving || !selectedInvoiceType}>
{#if isSaving}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Guardando...
@@ -882,4 +833,3 @@
</div>
</div>
</Tabs.Root>
{/if}

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { pedimentosApi, type Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import DataTable from '$lib/components/dashboard/pedimentos/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/pedimentos/columns.js';
import { createColumns } from '$lib/components/dashboard/pedimentos/columns';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -11,7 +11,7 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Edit, Send, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { Edit, Send, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { obtenerAtajosListaPedimento } from '$lib/config/shortcuts/dashboard/pedimentos/list';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { goto } from '$app/navigation';
@@ -20,20 +20,18 @@
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Estado para filtros
let filters = $state({
status: '',
pedimento: '',
year: ''
});
// Estado para filtros
let filters = $state({
status: '',
pedimento: '',
year: ''
});
let sorting = $state<import('@tanstack/table-core').SortingState>([{ id: 'id', desc: true }]);
let sorting = $state<import("@tanstack/table-core").SortingState>([{ id: 'id', desc: true }]);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
@@ -85,19 +83,12 @@
let loading = $state(false);
let isSaving = $state(false);
// Permissions
const canView = $derived(userHasPermission($currentUser, 'pedimentos_mgmt.view'));
const canCreate = $derived(userHasPermission($currentUser, 'pedimentos_mgmt.create'));
const canEdit = $derived(userHasPermission($currentUser, 'pedimentos_mgmt.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'pedimentos_mgmt.delete'));
// Efecto para reaccionar al cambio de ordenamiento
$effect(() => {
if (sorting.length >= 0 && canView) {
if (sorting.length >= 0) {
applyFilters();
}
});
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
@@ -122,20 +113,12 @@
}
function handleEditSelected() {
if (!canEdit) {
toast.error('No tienes permiso para editar pedimentos');
return;
}
if (selectedId) {
window.location.href = `/dashboard/pedimentos/edit/${selectedId}`;
}
}
function handleDelete() {
if (!canDelete) {
toast.error('No tienes permiso para eliminar pedimentos');
return;
}
if (!selectedId) {
return;
}
@@ -143,7 +126,6 @@
}
async function confirmDelete() {
if (!canDelete) return;
if (!selectedId) return;
const companyId = companyStore.activeCompany?.id;
@@ -298,7 +280,7 @@
);
async function loadMore() {
if (loading || !hasMore || !canView) return;
if (loading || !hasMore) return;
loading = true;
error = null;
@@ -352,7 +334,6 @@
}
async function applyFilters() {
if (!canView) return;
// Reset y recargar con filtros
loading = true;
error = null;
@@ -412,7 +393,6 @@
}
async function reloadData() {
if (!canView) return;
// Reset y recargar desde el principio usando la API
if (!companyStore.activeCompany) return;
@@ -465,10 +445,6 @@
}
function handleCreateClick() {
if (!canCreate) {
toast.error('No tienes permiso para crear pedimentos');
return;
}
// Redirigir a la página de creación (reusa la página de edición con ID "new")
window.location.href = '/dashboard/pedimentos/edit/new';
}
@@ -503,135 +479,127 @@
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
{#if !canView}
<ErrorState status={403} />
{:else}
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Pedimentos</h1>
<p class="text-muted-foreground">Gestiona los pedimentos del sistema</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
id="filter-status"
bind:value={filters.status}
onchange={applyFilters}
class="flex h-9 w-[220px] rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
title="Estado"
>
{#each statusOptions as option}
<option value={option.value}>
{option.value === '' ? 'Estado: Todos' : option.label}
</option>
{/each}
</select>
{#if canCreate}
<Button class="h-9" onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nuevo Pedimento
</Button>
{/if}
</div>
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Pedimentos</h1>
<p class="text-muted-foreground">Gestiona los pedimentos del sistema</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
id="filter-status"
bind:value={filters.status}
onchange={applyFilters}
class="flex h-9 w-[220px] rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
title="Estado"
>
{#each statusOptions as option}
<option value={option.value}>
{option.value === '' ? 'Estado: Todos' : option.label}
</option>
{/each}
</select>
<Button class="h-9" onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nuevo Pedimento
</Button>
</div>
</div>
<!-- Error Message -->
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
</Card.Root>
{/if}
<!-- Data Table -->
<Card.Root class="border bg-background flex flex-col min-h-0">
<!-- Error Message -->
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Pedimentos</Card.Title>
</div>
<div class="flex flex-wrap items-center gap-2">
<Input
id="filter-pedimento"
bind:value={filters.pedimento}
oninput={applyFilters}
placeholder="Buscar pedimento"
class="w-32 lg:w-48 h-9 bg-card"
/>
<Input
id="filter-year"
bind:value={filters.year}
oninput={applyFilters}
placeholder="Año"
maxlength={2}
class="w-24 h-9 bg-card"
/>
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
</div>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
<Card.Content class="p-0 overflow-hidden flex-1">
<!-- TanStack DataTable con Infinite Scroll -->
<div class="rounded-md border bg-background h-full overflow-hidden">
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
{selectedId}
onRowClick={handleRowClick}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
</div>
</Card.Content>
</Card.Root>
{/if}
<div class="flex-none text-sm text-muted-foreground mb-16">
Mostrando {allItems.length} de {totalItems} registros
<span class="ml-2"></span>
<span class="ml-2">Filtros activos: {Object.values(filters).filter((value) => value !== '').length}</span>
</div>
<!-- Footer fijo con botones de acción -->
<div
id="pedimento-list-footer"
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
{#if canEdit}
<Button variant="outline" size="sm" onclick={handleEditSelected} disabled={!hasSelection}>
<Edit size={16} class="mr-1" />
Editar
</Button>
{/if}
{#if canDelete}
<Button variant="destructive" size="sm" onclick={handleDelete} disabled={!hasSelection}>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
{/if}
<Button
variant="outline"
size="sm"
onclick={handleInterfaceAgenteAduanal}
disabled={!hasSelection}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
<!-- Data Table -->
<Card.Root class="border bg-background flex flex-col">
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Pedimentos</Card.Title>
</div>
<div class="flex flex-wrap items-center gap-2">
<Input
id="filter-pedimento"
bind:value={filters.pedimento}
oninput={applyFilters}
placeholder="Buscar pedimento"
class="w-32 lg:w-48 h-9 bg-card"
/>
<Input
id="filter-year"
bind:value={filters.year}
oninput={applyFilters}
placeholder="Año"
maxlength={2}
class="w-24 h-9 bg-card"
/>
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="p-0">
<!-- TanStack DataTable con Infinite Scroll -->
<div class="rounded-md border bg-background">
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
{selectedId}
onRowClick={handleRowClick}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {allItems.length} de {totalItems} registros
<span class="ml-2"></span>
<span class="ml-2">Filtros activos: {Object.values(filters).filter((value) => value !== '').length}</span>
</div>
<div class="h-20"></div>
<!-- Footer fijo con botones de acción -->
<div
id="pedimento-list-footer"
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" onclick={handleEditSelected} disabled={!hasSelection}>
<Edit size={16} class="mr-1" />
Editar
</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!hasSelection}>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleInterfaceAgenteAduanal}
disabled={!hasSelection}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
</Button>
</div>
</div>
{/if}
</div>
</div>
<!-- Diálogo de confirmación para borrar -->