Merge pull request 'feat: Implement CRUD operations for FDA catalog entries and their associated specifications, constituent elements, affirmation codes, and lot productions.' (#176) from feature/FDA into development

Reviewed-on: ADUANASOFT/anexo76#176
This commit is contained in:
2026-03-03 14:19:42 +00:00
16 changed files with 2587 additions and 9 deletions

View File

@@ -39,6 +39,7 @@ class FDACatalogUpdate(BaseModel):
}
class FDACatalogResponse(BaseModel):
"""DTO para respuesta del catálogo FDA"""
id: int
@@ -51,9 +52,121 @@ class FDACatalogResponse(BaseModel):
storage_status: Optional[str]
warehouse_code: Optional[str]
call_atl: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
model_config = {
"from_attributes": True
}
# DTOs para FDA01 - Especificaciones
class FDASpecificationsBase(BaseModel):
prod_code: Optional[str] = None
commodity_desc: Optional[str] = None
brand_name: Optional[str] = None
disclaimer: Optional[str] = None
pgm_code: Optional[str] = None
proc_code: Optional[str] = None
intnd_use_code: Optional[str] = None
intnd_use_desc: Optional[str] = None
temp_qual: Optional[str] = None
temp_type: Optional[str] = None
temp_degrees: Optional[float] = None
temp_negative: Optional[float] = None
temp_location: Optional[str] = None
quantity_1: Optional[float] = None
qty_uom_1: Optional[str] = None
quantity_2: Optional[float] = None
qty_uom_2: Optional[str] = None
quantity_3: Optional[float] = None
qty_uom_3: Optional[str] = None
ctry_prod: Optional[str] = None
ctry_source: Optional[str] = None
ctry_growth: Optional[str] = None
ctry_refusal: Optional[str] = None
ctry_shipping: Optional[str] = None
manuf_key: Optional[str] = None
shipper_key: Optional[str] = None
ult_cons_key: Optional[str] = None
fda_imp_key: Optional[str] = None
pn_subm_key: Optional[str] = None
consol_key: Optional[str] = None
producer_key: Optional[str] = None
owner_key: Optional[str] = None
deli_party_key: Optional[str] = None
grower_key: Optional[str] = None
dev_ini_imp_key: Optional[str] = None
lacf_cont_1: Optional[str] = None
lacf_cont_2: Optional[str] = None
lacf_cont_3: Optional[str] = None
pn_transmitter_key: Optional[str] = None
class FDASpecificationsCreate(FDASpecificationsBase):
pass
class FDASpecificationsUpdate(FDASpecificationsBase):
pass
class FDASpecificationsResponse(FDASpecificationsBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA04 - Elementos Constitutivos
class FDAConstituentElementsBase(BaseModel):
line: int
ele_name: str
ele_qty: Optional[float] = None
ele_qty_uom: Optional[str] = None
ele_pctg: Optional[float] = None
class FDAConstituentElementsCreate(FDAConstituentElementsBase):
pass
class FDAConstituentElementsUpdate(FDAConstituentElementsBase):
line: Optional[int] = None
ele_name: Optional[str] = None
class FDAConstituentElementsResponse(FDAConstituentElementsBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA23 - Códigos de Afirmación
class FDAAffirmationCodesBase(BaseModel):
line: int
aoc_code: str
aoc_qual: Optional[str] = None
class FDAAffirmationCodesCreate(FDAAffirmationCodesBase):
pass
class FDAAffirmationCodesUpdate(FDAAffirmationCodesBase):
line: Optional[int] = None
aoc_code: Optional[str] = None
class FDAAffirmationCodesResponse(FDAAffirmationCodesBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}
# DTOs para FDA25 - Lotes de Producción
class FDALotProductionBase(BaseModel):
line: int
lot_number: str
production_start_date: Optional[str] = None
production_end_date: Optional[str] = None
class FDALotProductionCreate(FDALotProductionBase):
pass
class FDALotProductionUpdate(FDALotProductionBase):
line: Optional[int] = None
lot_number: Optional[str] = None
class FDALotProductionResponse(FDALotProductionBase):
id: int
fda_catalog_id: int
model_config = {"from_attributes": True}

View File

@@ -1,9 +1,9 @@
"""
Modelo para el catálogo de claves FDA
"""
from typing import Optional
from sqlalchemy import String, Integer, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column
from typing import Optional, List
from sqlalchemy import String, Integer, Numeric, Date, ForeignKey, UniqueConstraint, PrimaryKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -27,3 +27,124 @@ class FDACatalog(Base, TenantScopedMixin, TimestampMixin):
storage_status: Mapped[Optional[str]] = mapped_column(String(100))
warehouse_code: Mapped[Optional[str]] = mapped_column(String(20))
call_atl: Mapped[Optional[str]] = mapped_column(String(20))
# Relationships
specifications: Mapped[Optional["FDASpecifications"]] = relationship("FDASpecifications", back_populates="fda_catalog", cascade="all, delete-orphan", uselist=False)
constituent_elements: Mapped[List["FDAConstituentElements"]] = relationship("FDAConstituentElements", back_populates="fda_catalog", cascade="all, delete-orphan")
affirmation_codes: Mapped[List["FDAAffirmationCodes"]] = relationship("FDAAffirmationCodes", back_populates="fda_catalog", cascade="all, delete-orphan")
lot_productions: Mapped[List["FDALotProduction"]] = relationship("FDALotProduction", back_populates="fda_catalog", cascade="all, delete-orphan")
class FDASpecifications(Base, TenantScopedMixin, TimestampMixin):
"""FDA01 - Especificaciones del Producto FDA"""
__tablename__ = "fda_specifications"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_specifications_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
prod_code: Mapped[Optional[str]] = mapped_column(String(50))
commodity_desc: Mapped[Optional[str]] = mapped_column(String(200))
brand_name: Mapped[Optional[str]] = mapped_column(String(100))
disclaimer: Mapped[Optional[str]] = mapped_column(String(100))
pgm_code: Mapped[Optional[str]] = mapped_column(String(50))
proc_code: Mapped[Optional[str]] = mapped_column(String(50))
intnd_use_code: Mapped[Optional[str]] = mapped_column(String(50))
intnd_use_desc: Mapped[Optional[str]] = mapped_column(String(200))
temp_qual: Mapped[Optional[str]] = mapped_column(String(50))
temp_type: Mapped[Optional[str]] = mapped_column(String(50))
temp_degrees: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
temp_negative: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
temp_location: Mapped[Optional[str]] = mapped_column(String(100))
quantity_1: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_1: Mapped[Optional[str]] = mapped_column(String(20))
quantity_2: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_2: Mapped[Optional[str]] = mapped_column(String(20))
quantity_3: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
qty_uom_3: Mapped[Optional[str]] = mapped_column(String(20))
ctry_prod: Mapped[Optional[str]] = mapped_column(String(50))
ctry_source: Mapped[Optional[str]] = mapped_column(String(50))
ctry_growth: Mapped[Optional[str]] = mapped_column(String(50))
ctry_refusal: Mapped[Optional[str]] = mapped_column(String(50))
ctry_shipping: Mapped[Optional[str]] = mapped_column(String(50))
manuf_key: Mapped[Optional[str]] = mapped_column(String(50))
shipper_key: Mapped[Optional[str]] = mapped_column(String(50))
ult_cons_key: Mapped[Optional[str]] = mapped_column(String(50))
fda_imp_key: Mapped[Optional[str]] = mapped_column(String(50))
pn_subm_key: Mapped[Optional[str]] = mapped_column(String(50))
consol_key: Mapped[Optional[str]] = mapped_column(String(50))
producer_key: Mapped[Optional[str]] = mapped_column(String(50))
owner_key: Mapped[Optional[str]] = mapped_column(String(50))
deli_party_key: Mapped[Optional[str]] = mapped_column(String(50))
grower_key: Mapped[Optional[str]] = mapped_column(String(50))
dev_ini_imp_key: Mapped[Optional[str]] = mapped_column(String(50))
lacf_cont_1: Mapped[Optional[str]] = mapped_column(String(100))
lacf_cont_2: Mapped[Optional[str]] = mapped_column(String(100))
lacf_cont_3: Mapped[Optional[str]] = mapped_column(String(100))
pn_transmitter_key: Mapped[Optional[str]] = mapped_column(String(50))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="specifications")
class FDAConstituentElements(Base, TenantScopedMixin, TimestampMixin):
"""FDA04 - Constituent Elements"""
__tablename__ = "fda_constituent_elements"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_constituent_elements_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
ele_name: Mapped[str] = mapped_column(String(200), nullable=False)
ele_qty: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
ele_qty_uom: Mapped[Optional[str]] = mapped_column(String(20))
ele_pctg: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="constituent_elements")
class FDAAffirmationCodes(Base, TenantScopedMixin, TimestampMixin):
"""FDA23 - Affirmation of Compliance"""
__tablename__ = "fda_affirmation_codes"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_affirmation_codes_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
aoc_code: Mapped[str] = mapped_column(String(50), nullable=False)
aoc_qual: Mapped[Optional[str]] = mapped_column(String(100))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="affirmation_codes")
class FDALotProduction(Base, TenantScopedMixin, TimestampMixin):
"""FDA25 - Lot and Production Dates"""
__tablename__ = "fda_lot_production"
__table_args__ = (
PrimaryKeyConstraint("id", name="fda_lot_production_pkey"),
{'schema': 'a76'}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fda_catalog_id: Mapped[int] = mapped_column(ForeignKey("a76.fda_catalog.id", ondelete="CASCADE"), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
lot_number: Mapped[str] = mapped_column(String(100), nullable=False)
production_start_date: Mapped[Optional[str]] = mapped_column(String(50))
production_end_date: Mapped[Optional[str]] = mapped_column(String(50))
fda_catalog: Mapped["FDACatalog"] = relationship("FDACatalog", back_populates="lot_productions")

View File

@@ -67,3 +67,204 @@ async def get_fda_catalog(
"warehouse_code": entry.warehouse_code,
"call_atl": entry.call_atl
}
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import FDACatalogCreate, FDACatalogUpdate
from sqlalchemy.exc import IntegrityError
@router.post("/", response_model=FDACatalogResponse, status_code=status.HTTP_201_CREATED)
async def create_fda_catalog(
data: FDACatalogCreate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Crear nueva entrada en el catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
try:
return FDACatalogService.create(db, tenant_id, company_id, data)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="La Clave FDA ya existe o hay un conflicto de datos.")
@router.put("/{id}", response_model=FDACatalogResponse)
async def update_fda_catalog(
id: int,
data: FDACatalogUpdate,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Actualizar entrada del catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
try:
entry = FDACatalogService.update(db, tenant_id, company_id, id, data)
if not entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entrada no encontrada")
return entry
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="Error de integridad de datos al actualizar la Clave FDA.")
@router.delete("/{id}")
async def delete_fda_catalog(
id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Eliminar entrada del catálogo FDA"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = FDACatalogService.delete(db, tenant_id, company_id, id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entrada no encontrada")
return {"message": "Eliminado correctamente"}
from typing import List
from api.v1.modules.a76.general_catalogs.fda_catalog.service import FDADetailsService
from api.v1.modules.a76.general_catalogs.fda_catalog.dto import (
FDASpecificationsCreate, FDASpecificationsResponse,
FDAConstituentElementsCreate, FDAConstituentElementsUpdate, FDAConstituentElementsResponse,
FDAAffirmationCodesCreate, FDAAffirmationCodesUpdate, FDAAffirmationCodesResponse,
FDALotProductionCreate, FDALotProductionUpdate, FDALotProductionResponse
)
# === FDA01: Especificaciones del Producto ===
@router.get("/{id}/specifications", response_model=Optional[FDASpecificationsResponse])
async def get_fda_specifications(
id: int,
company_id: int = Query(..., description="Company ID"),
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)
catalog_entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not catalog_entry:
raise HTTPException(status_code=404, detail="Catálogo FDA no encontrado")
return FDADetailsService.get_specifications(db, tenant_id, company_id, id)
@router.put("/{id}/specifications", response_model=FDASpecificationsResponse)
async def save_fda_specifications(
id: int,
data: FDASpecificationsCreate,
company_id: int = Query(..., description="Company ID"),
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)
catalog_entry = FDACatalogService.get_by_id(db, tenant_id, company_id, id)
if not catalog_entry:
raise HTTPException(status_code=404, detail="Catálogo FDA no encontrado")
return FDADetailsService.save_specifications(db, tenant_id, company_id, id, data.model_dump(exclude_unset=True))
# === FDA04: Elementos Constitutivos ===
@router.get("/{id}/constituent-elements", response_model=List[FDAConstituentElementsResponse])
async def list_fda_constituent_elements(
id: int,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.get_constituent_elements(db, tenant_id, company_id, id)
@router.post("/{id}/constituent-elements", response_model=FDAConstituentElementsResponse)
async def create_fda_constituent_element(
id: int,
data: FDAConstituentElementsCreate,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.create_constituent_element(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/constituent-elements/{element_id}")
async def delete_fda_constituent_element(
id: int,
element_id: int,
company_id: int = Query(..., description="Company ID"),
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)
success = FDADetailsService.delete_constituent_element(db, tenant_id, company_id, element_id)
if not success:
raise HTTPException(status_code=404, detail="Elemento no encontrado")
return {"message": "Eliminado correctamente"}
# === FDA23: Códigos de Afirmación ===
@router.get("/{id}/affirmation-codes", response_model=List[FDAAffirmationCodesResponse])
async def list_fda_affirmation_codes(
id: int,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.get_affirmation_codes(db, tenant_id, company_id, id)
@router.post("/{id}/affirmation-codes", response_model=FDAAffirmationCodesResponse)
async def create_fda_affirmation_code(
id: int,
data: FDAAffirmationCodesCreate,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.create_affirmation_code(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/affirmation-codes/{code_id}")
async def delete_fda_affirmation_code(
id: int,
code_id: int,
company_id: int = Query(..., description="Company ID"),
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)
success = FDADetailsService.delete_affirmation_code(db, tenant_id, company_id, code_id)
if not success:
raise HTTPException(status_code=404, detail="Código no encontrado")
return {"message": "Eliminado correctamente"}
# === FDA25: Lotes de Producción ===
@router.get("/{id}/lot-productions", response_model=List[FDALotProductionResponse])
async def list_fda_lot_productions(
id: int,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.get_lot_productions(db, tenant_id, company_id, id)
@router.post("/{id}/lot-productions", response_model=FDALotProductionResponse)
async def create_fda_lot_production(
id: int,
data: FDALotProductionCreate,
company_id: int = Query(..., description="Company ID"),
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 FDADetailsService.create_lot_production(db, tenant_id, company_id, id, data.model_dump())
@router.delete("/{id}/lot-productions/{lot_id}")
async def delete_fda_lot_production(
id: int,
lot_id: int,
company_id: int = Query(..., description="Company ID"),
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)
success = FDADetailsService.delete_lot_production(db, tenant_id, company_id, lot_id)
if not success:
raise HTTPException(status_code=404, detail="Lote no encontrado")
return {"message": "Eliminado correctamente"}

View File

@@ -106,3 +106,162 @@ class FDACatalogService:
db.delete(entry)
db.commit()
return True
class FDADetailsService:
"""Servicio CRUD para los detalles adicionales de FDA (Especificaciones, Lotes, etc)"""
# FDA01 - Especificaciones
@staticmethod
def get_specifications(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDASpecifications
return db.execute(
select(FDASpecifications).where(
(FDASpecifications.fda_catalog_id == fda_catalog_id) &
(FDASpecifications.tenant_id == tenant_id) &
(FDASpecifications.company_id == company_id)
)
).scalar_one_or_none()
@staticmethod
def save_specifications(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDASpecifications
existing = FDADetailsService.get_specifications(db, tenant_id, company_id, fda_catalog_id)
if existing:
for field, value in data.items():
setattr(existing, field, value)
entry = existing
else:
entry = FDASpecifications(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
# FDA04 - Elementos Constitutivos
@staticmethod
def get_constituent_elements(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
items = db.execute(
select(FDAConstituentElements).where(
(FDAConstituentElements.fda_catalog_id == fda_catalog_id) &
(FDAConstituentElements.tenant_id == tenant_id) &
(FDAConstituentElements.company_id == company_id)
).order_by(FDAConstituentElements.line.asc())
).scalars().all()
return items
@staticmethod
def create_constituent_element(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
entry = FDAConstituentElements(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_constituent_element(db: Session, tenant_id: int, company_id: int, element_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAConstituentElements
entry = db.execute(select(FDAConstituentElements).where(
(FDAConstituentElements.id == element_id) &
(FDAConstituentElements.tenant_id == tenant_id) &
(FDAConstituentElements.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False
# FDA23 - Códigos de Afirmación
@staticmethod
def get_affirmation_codes(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
items = db.execute(
select(FDAAffirmationCodes).where(
(FDAAffirmationCodes.fda_catalog_id == fda_catalog_id) &
(FDAAffirmationCodes.tenant_id == tenant_id) &
(FDAAffirmationCodes.company_id == company_id)
).order_by(FDAAffirmationCodes.line.asc())
).scalars().all()
return items
@staticmethod
def create_affirmation_code(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
entry = FDAAffirmationCodes(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_affirmation_code(db: Session, tenant_id: int, company_id: int, code_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDAAffirmationCodes
entry = db.execute(select(FDAAffirmationCodes).where(
(FDAAffirmationCodes.id == code_id) &
(FDAAffirmationCodes.tenant_id == tenant_id) &
(FDAAffirmationCodes.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False
# FDA25 - Lotes de Producción
@staticmethod
def get_lot_productions(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
items = db.execute(
select(FDALotProduction).where(
(FDALotProduction.fda_catalog_id == fda_catalog_id) &
(FDALotProduction.tenant_id == tenant_id) &
(FDALotProduction.company_id == company_id)
).order_by(FDALotProduction.line.asc())
).scalars().all()
return items
@staticmethod
def create_lot_production(db: Session, tenant_id: int, company_id: int, fda_catalog_id: int, data: dict):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
entry = FDALotProduction(
tenant_id=tenant_id,
company_id=company_id,
fda_catalog_id=fda_catalog_id,
**data
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
@staticmethod
def delete_lot_production(db: Session, tenant_id: int, company_id: int, lot_id: int):
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDALotProduction
entry = db.execute(select(FDALotProduction).where(
(FDALotProduction.id == lot_id) &
(FDALotProduction.tenant_id == tenant_id) &
(FDALotProduction.company_id == company_id)
)).scalar_one_or_none()
if entry:
db.delete(entry)
db.commit()
return True
return False

View File

@@ -135,7 +135,7 @@ async def sqlalchemy_error_handler(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "DATABASE_ERROR",
"message": "Error en la operación de base de datos",
"message": f"Error en la operación de base de datos: {str(exc)}",
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
},
)

View File

@@ -76,7 +76,8 @@
"goods": {
"title": "Goods",
"classes": "Classes",
"parts": "Parts"
"parts": "Parts",
"fda_codes": "FDA Codes"
},
"pedimentos": {
"title": "Pedimentos",

View File

@@ -76,7 +76,8 @@
"goods": {
"title": "Mercancías",
"classes": "Clases",
"parts": "Partes"
"parts": "Partes",
"fda_codes": "Códigos F.D.A."
},
"pedimentos": {
"title": "Pedimentos",

View File

@@ -17,7 +17,7 @@ import {
Ship,
Truck,
Users,
} from 'lucide-svelte';
} from '@lucide/svelte';
import * as m from "$lib/paraglide/messages.js";
import { Title } from '../ui/alert';
@@ -366,6 +366,10 @@ export function getSidebarData(): SidebarData {
{
title: m["sidebar.goods.parts"](),
url: "/dashboard/goods/parts",
},
{
title: m["sidebar.goods.fda_codes"](),
url: "/dashboard/goods/fda-codes",
}
],
},

View File

@@ -0,0 +1,283 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
import * as fs from 'fs';
// Helper to log to a file for debugging
const debugLog = (msg: string) => {
try {
const timestamp = new Date().toISOString();
fs.appendFileSync('/tmp/fda_proxy_debug.log', `[${timestamp}] ${msg}\n`);
} catch (e) { }
};
// Precise cleaners to ensure only business data is sent to backend
const cleanFDA01 = (d: any) => {
if (!d) return null;
return {
prod_code: d.prod_code || null,
commodity_desc: d.commodity_desc || null,
brand_name: d.brand_name || null,
disclaimer: d.disclaimer || null,
pgm_code: d.pgm_code || null,
proc_code: d.proc_code || null,
intnd_use_code: d.intnd_use_code || null,
intnd_use_desc: d.intnd_use_desc || null,
temp_qual: d.temp_qual || null,
temp_type: d.temp_type || null,
temp_degrees: d.temp_degrees || null,
temp_negative: d.temp_negative || null,
temp_location: d.temp_location || null,
quantity_1: d.quantity_1 || null,
qty_uom_1: d.qty_uom_1 || null,
quantity_2: d.quantity_2 || null,
qty_uom_2: d.qty_uom_2 || null,
quantity_3: d.quantity_3 || null,
qty_uom_3: d.qty_uom_3 || null,
ctry_prod: d.ctry_prod || null,
ctry_source: d.ctry_source || null,
ctry_growth: d.ctry_growth || null,
ctry_refusal: d.ctry_refusal || null,
ctry_shipping: d.ctry_shipping || null,
manuf_key: d.manuf_key || null,
shipper_key: d.shipper_key || null,
ult_cons_key: d.ult_cons_key || null,
fda_imp_key: d.fda_imp_key || null,
pn_subm_key: d.pn_subm_key || null,
consol_key: d.consol_key || null,
producer_key: d.producer_key || null,
owner_key: d.owner_key || null,
deli_party_key: d.deli_party_key || null,
grower_key: d.grower_key || null,
dev_ini_imp_key: d.dev_ini_imp_key || null,
lacf_cont_1: d.lacf_cont_1 || null,
lacf_cont_2: d.lacf_cont_2 || null,
lacf_cont_3: d.lacf_cont_3 || null,
pn_transmitter_key: d.pn_transmitter_key || null
};
};
const cleanFDA23 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
aoc_code: d.aoc_code || '',
aoc_qual: d.aoc_qual || null
});
const cleanFDA04 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
ele_name: d.ele_name || '',
ele_qty: (d.ele_qty === '' || d.ele_qty === null || d.ele_qty === undefined) ? null : parseFloat(d.ele_qty),
ele_qty_uom: d.ele_qty_uom || null,
ele_pctg: (d.ele_pctg === '' || d.ele_pctg === null || d.ele_pctg === undefined) ? null : parseFloat(d.ele_pctg)
});
const cleanFDA25 = (d: any) => ({
line: typeof d.line === 'number' ? d.line : (parseInt(d.line) || 1),
lot_number: d.lot_number || '',
production_start_date: d.production_start_date || null,
production_end_date: d.production_end_date || null
});
async function saveNestedData(id: string, companyId: string, nested: any, cookies: any, fetch: any) {
debugLog(`saveNestedData started for id: ${id}`);
const { fda01, fda23_codes, fda04_elements, fda25_lots } = nested;
// 1. FDA01 Specifications
if (fda01) {
debugLog(`Saving FDA01`);
const res = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/specifications?company_id=${companyId}`,
{ method: 'PUT', body: JSON.stringify(cleanFDA01(fda01)) },
cookies, fetch
);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA01: ${res.status} - ${txt}`);
throw new Error(`Error en FDA01: ${res.status} - ${txt}`);
}
}
// 2. FDA23 Affirmation Codes
debugLog(`Processing FDA23`);
const resExist23 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist23.ok) {
const items = await resExist23.json();
debugLog(`Deleting ${items.length} existing FDA23 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda23_codes && Array.isArray(fda23_codes)) {
for (const code of fda23_codes) {
if (!code.aoc_code) continue;
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA23(code)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA23: ${res.status} - ${txt}`);
throw new Error(`Error en FDA23: ${res.status} - ${txt}`);
}
}
}
// 3. FDA04 Constituent Elements
debugLog(`Processing FDA04`);
const resExist04 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist04.ok) {
const items = await resExist04.json();
debugLog(`Deleting ${items.length} existing FDA04 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda04_elements && Array.isArray(fda04_elements)) {
for (const ele of fda04_elements) {
if (!ele.ele_name) continue;
debugLog(`Saving FDA04 item: ${ele.ele_name}`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA04(ele)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA04: ${res.status} - ${txt}`);
throw new Error(`Error en FDA04: ${res.status} - ${txt}`);
}
}
}
// 4. FDA25 Lot Productions
debugLog(`Processing FDA25`);
const resExist25 = await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`, { method: 'GET' }, cookies, fetch);
if (resExist25.ok) {
const items = await resExist25.json();
debugLog(`Deleting ${items.length} existing FDA25 items`);
for (const item of items) {
await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions/${item.id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
}
}
if (fda25_lots && Array.isArray(fda25_lots)) {
for (const lot of fda25_lots) {
if (!lot.lot_number) continue;
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(cleanFDA25(lot)) }, cookies, fetch);
if (!res.ok) {
const txt = await res.text();
debugLog(`Error FDA25: ${res.status} - ${txt}`);
throw new Error(`Error en FDA25: ${res.status} - ${txt}`);
}
}
}
debugLog(`saveNestedData finished successfully`);
}
export const GET: RequestHandler = async ({ url, cookies, fetch }) => {
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const skip = url.searchParams.get('skip') || '0';
const limit = url.searchParams.get('limit') || '50';
const search = url.searchParams.get('search') || '';
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
let apiUrl = `v1/a76/fda-catalog/?company_id=${companyId}&skip=${skip}&limit=${limit}`;
if (search) apiUrl += `&search=${encodeURIComponent(search)}`;
const response = await authenticatedFetch(apiUrl, { method: 'GET' }, cookies, fetch);
if (!response.ok) return json({ error: 'Error al cargar registros FDA' }, { status: response.status });
const data = await response.json();
return json(data);
} catch (error) {
return json({ error: 'Error de conexión' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies, fetch }) => {
debugLog(`POST started`);
const companyId = cookies.get('active_company_id');
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const body = await request.json();
const mainRecord = {
fda_key: body.fda_key,
description: body.description,
fda_code: body.fda_code || null,
requirements: body.requirements || null,
manufacturer_number: body.manufacturer_number || null,
country_of_production: body.country_of_production || null,
storage_status: body.storage_status || null,
warehouse_code: body.warehouse_code || null,
call_atl: body.call_atl || null
};
debugLog(`Creating main record`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/?company_id=${companyId}`, { method: 'POST', body: JSON.stringify(mainRecord) }, cookies, fetch);
const data = await res.json();
if (!res.ok) {
debugLog(`Error creating main record: ${res.status}`);
return json({ error: data.detail || 'Error al crear FDA' }, { status: res.status });
}
await saveNestedData(data.id.toString(), companyId, body, cookies, fetch);
debugLog(`POST finished successfully`);
return json(data);
} catch (error: any) {
debugLog(`POST Crash: ${error.message}`);
console.error('POST Error:', error);
return json({ error: error.message || 'Error al procesar el guardado' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ request, url, cookies, fetch }) => {
debugLog(`PUT started`);
const id = url.searchParams.get('id');
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
debugLog(`PUT params: id=${id}, companyId=${companyId}`);
if (!id || !companyId) return json({ error: 'ID o compañía no proporcionada' }, { status: 400 });
try {
const body = await request.json();
const mainRecord = {
fda_key: body.fda_key,
description: body.description,
fda_code: body.fda_code || null,
requirements: body.requirements || null,
manufacturer_number: body.manufacturer_number || null,
country_of_production: body.country_of_production || null,
storage_status: body.storage_status || null,
warehouse_code: body.warehouse_code || null,
call_atl: body.call_atl || null
};
debugLog(`Updating main record id=${id}`);
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}?company_id=${companyId}`, { method: 'PUT', body: JSON.stringify(mainRecord) }, cookies, fetch);
const data = await res.json();
if (!res.ok) {
debugLog(`Error updating main record: ${res.status}`);
return json({ error: data.detail || 'Error al actualizar FDA' }, { status: res.status });
}
await saveNestedData(id, companyId, body, cookies, fetch);
debugLog(`PUT finished successfully`);
return json(data);
} catch (error: any) {
debugLog(`PUT Crash: ${error.message}`);
console.error('PUT Error:', error);
return json({ error: error.message || 'Error al procesar la actualización' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ url, cookies, fetch }) => {
const id = url.searchParams.get('id');
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
if (!id || !companyId) return json({ error: 'ID o compañía no proporcionada' }, { status: 400 });
try {
const res = await authenticatedFetch(`v1/a76/fda-catalog/${id}?company_id=${companyId}`, { method: 'DELETE' }, cookies, fetch);
if (!res.ok) {
const data = await res.json();
return json({ error: data.detail || 'Error al eliminar FDA' }, { status: res.status });
}
return json({ success: true });
} catch (error) {
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar registro FDA' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in fda-catalog detail API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/affirmation-codes?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar affirmation codes' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in affirmation-codes API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/constituent-elements?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar elementos constitutivos' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in constituent-elements API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/lot-productions?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar lot productions' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in lot-productions API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, url, cookies, fetch }) => {
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) return json({ error: 'No autorizado' }, { status: 401 });
const companyId = url.searchParams.get('company_id') || cookies.get('active_company_id');
const id = params.id;
if (!companyId) return json({ error: 'Compañía no seleccionada' }, { status: 400 });
try {
const response = await authenticatedFetch(
`v1/a76/fda-catalog/${id}/specifications?company_id=${companyId}`,
{ method: 'GET', cache: 'no-store' },
cookies, fetch
);
if (!response.ok) {
return json({ error: 'Error al cargar especificaciones' }, { status: response.status });
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Error in specifications API:', error);
return json({ error: 'Error de conexión' }, { status: 500 });
}
};

View File

@@ -0,0 +1,469 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import {
Plus,
RefreshCw,
FileText,
Pencil,
Trash2,
Search,
Filter,
Building2,
Globe,
Box,
CheckSquare,
Square,
AlertCircle
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Checkbox } from '$lib/components/ui/checkbox';
import { cn } from '$lib/utils';
let fdaList = $state<any[]>([]);
let loading = $state(false);
let selectedRows = $state<number[]>([]);
let mounted = $state(false);
let searchQuery = $state('');
let hoveredRow = $state<number | null>(null);
onMount(() => {
mounted = true;
});
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (mounted && companyId) {
fdaList = [];
loadAllFdaData();
}
});
// Filtrar por búsqueda
const filteredFdaList = $derived(
searchQuery.trim()
? fdaList.filter(
(fda) =>
fda.fda_key?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.fda_code?.toLowerCase().includes(searchQuery.toLowerCase()) ||
fda.country_of_production?.toLowerCase().includes(searchQuery.toLowerCase())
)
: fdaList
);
async function loadAllFdaData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
try {
const res = await fetch(`/api-sveltekit/fda-catalog?company_id=${companyId}`);
if (res.ok) {
const data = await res.json();
fdaList = data.items || [];
selectedRows = [];
}
} catch (error) {
console.error('Error cargando lista de FDA:', error);
} finally {
loading = false;
}
}
function handleCreateClick() {
goto('/dashboard/goods/fda-codes/edit/new');
}
function handleEditClick(fdaId: number) {
goto(`/dashboard/goods/fda-codes/edit/${fdaId}`);
}
async function deleteFdaCode(id: number, event?: MouseEvent) {
event?.stopPropagation();
if (!confirm('¿Está seguro de eliminar este código FDA?')) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
try {
const res = await fetch(`/api-sveltekit/fda-catalog?id=${id}&company_id=${companyId}`, {
method: 'DELETE'
});
if (res.ok) {
toast.success('Registro eliminado correctamente');
selectedRows = selectedRows.filter((r) => r !== id);
fdaList = fdaList.filter((f) => f.id !== id);
} else {
const error = await res.json();
toast.error(error.error || 'Error al eliminar');
}
} catch (err) {
console.error('Error:', err);
toast.error('Error de conexión');
}
}
function toggleRowSelection(id: number) {
if (selectedRows.includes(id)) {
selectedRows = selectedRows.filter((r) => r !== id);
} else {
selectedRows = [...selectedRows, id];
}
}
function toggleAllSelection(checked: boolean) {
if (checked) {
selectedRows = filteredFdaList.map((row) => row.id);
} else {
selectedRows = [];
}
}
async function handleDeleteSelected() {
if (selectedRows.length === 0) return;
if (!confirm(`¿Está seguro de eliminar ${selectedRows.length} código(s) FDA?`)) return;
for (const id of selectedRows) {
await deleteFdaCode(id);
}
}
function handleEditSelected() {
if (selectedRows.length !== 1) return;
const id = selectedRows[0];
handleEditClick(id);
}
function clearSelection() {
selectedRows = [];
}
// Obtener badge de estado de almacenaje
function getStorageBadge(status: string) {
const styles: Record<string, string> = {
ACTIVO: 'bg-green-100 text-green-700 border-green-200',
INACTIVO: 'bg-gray-100 text-gray-700 border-gray-200',
PENDIENTE: 'bg-yellow-100 text-yellow-700 border-yellow-200'
};
return styles[status?.toUpperCase()] || 'bg-blue-100 text-blue-700 border-blue-200';
}
</script>
<div class="min-h-screen bg-gradient-to-b from-background to-muted/20 pb-28">
<!-- Header Principal -->
<div class="sticky top-0 z-20 border-b bg-card/50 backdrop-blur-sm">
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<FileText class="h-6 w-6 text-primary" />
</div>
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">Códigos F.D.A.</h1>
</div>
<p class="pl-[52px] text-sm text-muted-foreground sm:text-base">
Gestión de códigos FDA para mercancías / FDA goods code management
</p>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<Button
variant="outline"
size="sm"
onclick={loadAllFdaData}
disabled={loading}
class="h-10"
>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
<span class="hidden sm:inline">Actualizar</span>
</Button>
<Button onclick={handleCreateClick} size="sm" class="h-10 shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Nuevo Código</span>
<span class="sm:hidden">Nuevo</span>
</Button>
</div>
</div>
</div>
</div>
<!-- Contenido Principal -->
<div class="mx-auto max-w-[1600px] px-4 py-6 sm:px-6">
<Card.Root class="overflow-hidden border-0 shadow-xl shadow-black/5">
<!-- Header de Tabla con Búsqueda -->
<div class="border-b bg-muted/30 p-4 sm:p-6">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-primary/10 p-2">
<Filter class="h-4 w-4 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold">Registros FDA</h3>
<p class="text-sm text-muted-foreground">
{filteredFdaList.length} de {fdaList.length} registros
</p>
</div>
</div>
<div class="relative w-full sm:w-80">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
placeholder="Buscar por clave, código, descripción..."
bind:value={searchQuery}
class="h-10 bg-background pl-10"
/>
</div>
</div>
<!-- Barra de selección -->
{#if selectedRows.length > 0}
<div
class="animate-in fade-in slide-in-from-top-2 mt-4 flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 p-3"
>
<div class="flex items-center gap-3">
<CheckSquare class="h-5 w-5 text-primary" />
<span class="text-sm font-medium">
{selectedRows.length}
{selectedRows.length === 1 ? 'registro seleccionado' : 'registros seleccionados'}
</span>
</div>
<Button variant="ghost" size="sm" onclick={clearSelection} class="h-8">
Limpiar selección
</Button>
</div>
{/if}
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/50 hover:bg-muted/50">
<Table.Head class="w-12 text-center">
<Checkbox
checked={filteredFdaList.length > 0 &&
selectedRows.length === filteredFdaList.length}
indeterminate={selectedRows.length > 0 &&
selectedRows.length < filteredFdaList.length}
onCheckedChange={toggleAllSelection}
aria-label="Seleccionar todos"
/>
</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Clave FDA</Table.Head>
<Table.Head class="font-semibold">Descripción</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Código FDA</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Fabricante</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">País</Table.Head>
<Table.Head class="font-semibold whitespace-nowrap">Estado</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-primary/10 p-4">
<RefreshCw class="h-8 w-8 animate-spin text-primary" />
</div>
<div>
<p class="text-lg font-medium">Cargando registros...</p>
<p class="text-sm text-muted-foreground">Por favor espere</p>
</div>
</div>
</Table.Cell>
</Table.Row>
{:else if filteredFdaList.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="py-16 text-center">
{#if searchQuery}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Search class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No se encontraron resultados</p>
<p class="text-sm text-muted-foreground">Intenta con otra búsqueda</p>
</div>
<Button variant="outline" size="sm" onclick={() => (searchQuery = '')}>
Limpiar búsqueda
</Button>
</div>
{:else}
<div class="flex flex-col items-center gap-4">
<div class="rounded-full bg-muted p-4">
<Box class="h-8 w-8 text-muted-foreground" />
</div>
<div>
<p class="text-lg font-medium">No hay registros FDA</p>
<p class="text-sm text-muted-foreground">
No se encontraron registros para esta compañía
</p>
</div>
</div>
{/if}
</Table.Cell>
</Table.Row>
{:else}
{#each filteredFdaList as fda, i (fda.id)}
<Table.Row
class={cn(
'cursor-pointer transition-all duration-150',
selectedRows.includes(fda.id) && 'bg-primary/5 hover:bg-primary/10',
hoveredRow === fda.id && !selectedRows.includes(fda.id) && 'bg-muted/30',
'hover:shadow-sm'
)}
onmouseenter={() => (hoveredRow = fda.id)}
onmouseleave={() => (hoveredRow = null)}
onclick={() => toggleRowSelection(fda.id)}
>
<Table.Cell class="text-center" onclick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedRows.includes(fda.id)}
onCheckedChange={() => toggleRowSelection(fda.id)}
aria-label="Seleccionar fila"
/>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<span
class="rounded bg-primary/10 px-2 py-1 font-mono text-sm font-bold text-primary"
>
{fda.fda_key}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="max-w-[300px]">
<p class="truncate font-medium" title={fda.description}>
{fda.description || '-'}
</p>
{#if fda.requirements}
<p
class="mt-0.5 truncate text-xs text-muted-foreground"
title={fda.requirements}
>
{fda.requirements}
</p>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-sm">
{fda.fda_code || '-'}
</span>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Building2 class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.manufacturer_number || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Globe class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm">{fda.country_of_production || '-'}</span>
</div>
</Table.Cell>
<Table.Cell>
{#if fda.storage_status}
<Badge
variant="outline"
class={cn('text-xs font-medium', getStorageBadge(fda.storage_status))}
>
{fda.storage_status}
</Badge>
{:else}
<span class="text-sm text-muted-foreground">-</span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Footer de Tabla -->
{#if filteredFdaList.length > 0}
<div class="flex items-center justify-between border-t bg-muted/30 px-4 py-3">
<span class="text-sm text-muted-foreground">
Mostrando {filteredFdaList.length} registros
</span>
{#if searchQuery}
<Badge variant="secondary" class="gap-2">
Filtro: "{searchQuery}"
<button onclick={() => (searchQuery = '')} class="hover:text-destructive"> × </button>
</Badge>
{/if}
</div>
{/if}
</Card.Root>
</div>
<!-- Footer Fijo con Acciones -->
<div
class="fixed right-0 bottom-0 left-0 z-30 border-t bg-background/95 shadow-[0_-4px_20px_rgba(0,0,0,0.1)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="mx-auto max-w-[1600px] px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<!-- Info de selección -->
<div class="flex items-center gap-4">
{#if selectedRows.length > 0}
<div class="flex items-center gap-3 rounded-full bg-primary/10 px-4 py-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span class="text-sm font-semibold text-primary">
{selectedRows.length} seleccionado(s)
</span>
</div>
{:else}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="h-4 w-4" />
<span>Selecciona registros para editar o eliminar</span>
</div>
{/if}
</div>
<!-- Botones de acción -->
<div class="flex items-center gap-2">
<Button size="default" onclick={handleCreateClick} class="shadow-lg shadow-primary/20">
<Plus class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Insertar</span>
<span class="sm:hidden">Nuevo</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length !== 1}
onclick={handleEditSelected}
class={selectedRows.length === 1 ? 'border-primary/50' : ''}
>
<Pencil class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Editar</span>
</Button>
<Button
variant="outline"
size="default"
disabled={selectedRows.length === 0}
onclick={handleDeleteSelected}
class={selectedRows.length > 0
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: ''}
>
<Trash2 class="mr-2 h-4 w-4" />
<span class="hidden sm:inline">Borrar</span>
</Button>
</div>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff