chore: rescate y restauracion de modulos base, a76 y digitalizacion tras conflictos
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "#",
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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="Añ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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user