Merge pull request 'feature/catalogo-equivalencias' (#225) from feature/catalogo-equivalencias into development

Reviewed-on: ADUANASOFT/anexo76#225
This commit is contained in:
2026-03-18 20:34:35 +00:00
15 changed files with 1130 additions and 506 deletions

View File

@@ -1,39 +1,42 @@
from typing import Optional, List
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field, ConfigDict
# Equivalency Item DTOs
# EquivalencyItem DTOs (pool global independiente)
class EquivalencyItemBase(BaseModel):
original_field: str = Field(..., max_length=100,
description="Original Field (Unit of Measure)")
external_field: str = Field(..., max_length=100,
description="External Field")
original_field: str = Field(..., max_length=100, description="Campo original (from)")
external_field: str = Field(..., max_length=100, description="Campo externo (to)")
conversion_factor: Optional[Decimal] = Field(None, description="Factor de conversión")
class EquivalencyItemCreate(EquivalencyItemBase):
pass
conversion_factor: Optional[Decimal] = Field(None, description="Factor de conversión")
class EquivalencyItemUpdate(BaseModel):
original_field: Optional[str] = Field(None, max_length=100)
external_field: Optional[str] = Field(None, max_length=100)
conversion_factor: Optional[Decimal] = None
class EquivalencyItemResponse(EquivalencyItemBase):
id: int
equivalency_id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
# Equivalency DTOs
# Equivalency DTOs (catálogo — referencia un item del pool)
class EquivalencyBase(BaseModel):
fraccion_mex: str = Field(..., max_length=10, description="Fraccion MX (Identifier)")
fraccion_us: str = Field(..., max_length=100, description="Fraccion US (External Field)")
description: Optional[str] = Field(
None, max_length=200, description="Description")
identifier: str = Field(..., max_length=10, description="Identificador único")
description: Optional[str] = Field(None, max_length=200, description="Descripción (opcional)")
item_id: Optional[int] = Field(None, description="ID del EquivalencyItem seleccionado")
class EquivalencyCreate(EquivalencyBase):
@@ -41,16 +44,15 @@ class EquivalencyCreate(EquivalencyBase):
class EquivalencyUpdate(BaseModel):
fraccion_mex: Optional[str] = Field(None, max_length=10)
fraccion_us: Optional[str] = Field(None, max_length=100)
identifier: Optional[str] = Field(None, max_length=10)
description: Optional[str] = Field(None, max_length=200)
item_id: Optional[int] = Field(None, description="Re-asociar a otro EquivalencyItem")
class EquivalencyResponse(EquivalencyBase):
id: int
tenant_id: int
company_id: int
model_config = ConfigDict(from_attributes=True)
item: Optional[EquivalencyItemResponse] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,12 +1,39 @@
from typing import Optional, List
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, ForeignKeyConstraint
from decimal import Decimal
from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
"""Pool global de mapeos campo_original → campo_externo.
Un mismo item puede ser referenciado por múltiples Equivalency."""
__tablename__ = "equivalency_items"
__table_args__ = (
UniqueConstraint(
"original_field", "external_field", "tenant_id", "company_id",
name="uq_equivalency_item_fields"
),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
original_field: Mapped[str] = mapped_column(String(100), nullable=False)
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
conversion_factor: Mapped[Optional[Decimal]] = mapped_column(
Numeric(13, 6), nullable=True)
equivalencies: Mapped[List["Equivalency"]] = relationship(
back_populates="item", foreign_keys="[Equivalency.item_id]")
class Equivalency(Base, TenantScopedMixin, TimestampMixin):
"""Catálogo de equivalencias. Cada registro selecciona UN item del pool."""
__tablename__ = "equivalencies"
__table_args__ = (
UniqueConstraint("identifier", "tenant_id", "company_id",
@@ -18,38 +45,13 @@ class Equivalency(Base, TenantScopedMixin, TimestampMixin):
Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(String(10), nullable=False)
description: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True)
items: Mapped[List["EquivalencyItem"]] = relationship(
back_populates="equivalency", cascade="all, delete-orphan")
item_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("a76.equivalency_items.id", ondelete="SET NULL"),
nullable=True)
class EquivalencyItem(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "equivalency_items"
__table_args__ = (
UniqueConstraint("equivalency_id", "original_field",
"external_field", "tenant_id", "company_id", name="uq_equivalency_item_fields"),
ForeignKeyConstraint(
["original_field", "tenant_id", "company_id"],
["a76.units_of_measure.code", "a76.units_of_measure.tenant_id",
"a76.units_of_measure.company_id"]
),
{"schema": "a76"}
)
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
equivalency_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.equivalencies.id"), nullable=False)
original_field: Mapped[str] = mapped_column(
String(100), nullable=False) # Relation to Unit of Measure
external_field: Mapped[str] = mapped_column(String(100), nullable=False)
equivalency: Mapped["Equivalency"] = relationship(back_populates="items")
unit_of_measure: Mapped["UnitOfMeasure"] = relationship()
item: Mapped[Optional["EquivalencyItem"]] = relationship(
back_populates="equivalencies", foreign_keys=[item_id])

View File

@@ -1,12 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from . import service
from .models import Equivalency, EquivalencyItem
from .dto import (
EquivalencyCreate, EquivalencyResponse, EquivalencyUpdate,
EquivalencyItemCreate, EquivalencyItemResponse, EquivalencyItemUpdate
@@ -16,19 +9,7 @@ from .service import EquivalencyService, EquivalencyItemService
router = APIRouter(prefix="/equivalencies",
tags=["a76.general_catalogs.equivalencies"])
# Equivalency CRUD
equivalency_crud = TenantCRUDRoutes(
service=EquivalencyService,
create_schema=EquivalencyCreate,
update_schema=EquivalencyUpdate,
response_schema=EquivalencyResponse,
prefix="",
tags=["Equivalencies"],
resource_name="Equivalency",
enable_list=True,
)
# Equivalency Item CRUD
# Pool global de EquivalencyItems
item_crud = TenantCRUDRoutes(
service=EquivalencyItemService,
create_schema=EquivalencyItemCreate,
@@ -38,62 +19,21 @@ item_crud = TenantCRUDRoutes(
tags=["Equivalency Items"],
resource_name="EquivalencyItem",
enable_list=True,
enable_filters=True,
)
# Custom endpoint for creating items nested under equivalency
@equivalency_crud.router.post(
"/{equivalency_id}/items",
response_model=EquivalencyItemResponse,
status_code=status.HTTP_201_CREATED,
summary="Create equivalency item",
# Catálogo de equivalencias (referencia item_id)
equivalency_crud = TenantCRUDRoutes(
service=EquivalencyService,
create_schema=EquivalencyCreate,
update_schema=EquivalencyUpdate,
response_schema=EquivalencyResponse,
prefix="",
tags=["Equivalencies"],
resource_name="Equivalency",
enable_list=True,
enable_filters=True,
)
async def create_equivalency_item(
equivalency_id: int,
data: EquivalencyItemCreate,
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)
# Verify parent exists
parent = EquivalencyService.get_by_id(
db, equivalency_id, tenant_id, company_id)
if not parent:
raise HTTPException(status_code=404, detail="Equivalency not found")
# Create item
# We need to manually handle the creation because the DTO doesn't have equivalency_id
# and the service.create expects data to match the model or DTO.
# But service.create takes EquivalencyItemCreate which doesn't have equivalency_id.
# So we need to modify the data or handle it in service.
# Actually, I implemented EquivalencyItemService.create to take EquivalencyItemCreate.
# And it tries to create the model.
# But the model needs equivalency_id.
# So EquivalencyItemService.create will fail if I don't pass equivalency_id.
# I should update EquivalencyItemService.create to accept extra kwargs or handle this.
# Let's update the service call here to pass equivalency_id manually if I can't change the service signature easily.
# But wait, I can just instantiate the model here or update the service.
# I'll update the service to handle it.
# But for now, let's assume I can pass it in the data if I convert it to dict.
item_data = data.model_dump()
item_data['equivalency_id'] = equivalency_id
# I need to call a method that accepts this.
# EquivalencyItemService.create takes EquivalencyItemCreate.
# I should probably add a specific method for this or update create.
# Let's use a direct DB call here or add a method to service.
# Adding a method to service is cleaner.
return EquivalencyItemService.create_nested(db, equivalency_id, data, tenant_id, company_id)
router.include_router(equivalency_crud.router)
router.include_router(item_crud.router)
router.include_router(equivalency_crud.router)

View File

@@ -1,6 +1,5 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
@@ -8,203 +7,9 @@ from .models import Equivalency, EquivalencyItem
from .dto import EquivalencyCreate, EquivalencyUpdate, EquivalencyItemCreate, EquivalencyItemUpdate
class EquivalencyService:
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Equivalency], int]:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).options(joinedload(Equivalency.items))
if filters:
if 'fraccion_mex' in filters:
query = query.filter(Equivalency.identifier.ilike(f"%{filters['fraccion_mex']}%"))
if 'description' in filters:
query = query.filter(Equivalency.description.ilike(f"%{filters['description']}%"))
total = query.count()
items = query.offset(skip).limit(limit).all()
# Map internal fields to DTO fields
for item in items:
item.fraccion_mex = item.identifier
# Try to find the first item to get fraccion_us
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
item = db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).options(joinedload(Equivalency.items)).first()
if item:
item.fraccion_mex = item.identifier
if item.items:
item.fraccion_us = item.items[0].external_field
else:
item.fraccion_us = ""
return item
@staticmethod
def create(
db: Session,
data: EquivalencyCreate,
tenant_id: int,
company_id: int
) -> Equivalency:
try:
# Create Parent
db_obj = Equivalency(
identifier=data.fraccion_mex,
description=data.description,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.flush() # Flush to get ID
# Create Child Item (mapping fraccion_mex -> original_field, fraccion_us -> external_field)
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex, # Must exist in units_of_measure
external_field=data.fraccion_us,
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
# Map for response
db_obj.fraccion_mex = db_obj.identifier
db_obj.fraccion_us = item.external_field
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in create: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe una equivalencia para la Fracción MX '{data.fraccion_mex}'."
)
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in create: {str(e)}")
raise e
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
try:
if data.fraccion_mex:
db_obj.identifier = data.fraccion_mex
if data.description:
db_obj.description = data.description
# Update Item
item = None
if db_obj.items:
item = db_obj.items[0]
if item:
if data.fraccion_us:
item.external_field = data.fraccion_us
if data.fraccion_mex:
item.original_field = data.fraccion_mex
else:
# Create if missing
if data.fraccion_us or data.fraccion_mex:
item = EquivalencyItem(
equivalency_id=db_obj.id,
original_field=data.fraccion_mex or db_obj.identifier,
external_field=data.fraccion_us or "",
tenant_id=tenant_id,
company_id=company_id
)
db.add(item)
db.commit()
db.refresh(db_obj)
# Map for response
db_obj.fraccion_mex = db_obj.identifier
if db_obj.items:
db_obj.fraccion_us = db_obj.items[0].external_field
else:
db_obj.fraccion_us = ""
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
print(f"IntegrityError in update: {error_msg}")
if "units_of_measure" in error_msg:
raise HTTPException(
status_code=400,
detail=f"La Fracción MX '{data.fraccion_mex or db_obj.identifier}' no es válida. Debe existir en el catálogo de Unidades de Medida."
)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}")
except Exception as e:
db.rollback()
print(f"Error in update: {str(e)}")
raise e
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True
class EquivalencyItemService:
"""CRUD simple sobre el pool global de items."""
@staticmethod
def get_all(
db: Session,
@@ -216,8 +21,19 @@ class EquivalencyItemService:
) -> Tuple[List[EquivalencyItem], int]:
query = db.query(EquivalencyItem).filter(
EquivalencyItem.tenant_id == tenant_id,
EquivalencyItem.company_id == company_id
EquivalencyItem.company_id == company_id,
)
if filters:
if "original_field" in filters:
query = query.filter(
EquivalencyItem.original_field.ilike(f"%{filters['original_field']}%")
)
if "external_field" in filters:
query = query.filter(
EquivalencyItem.external_field.ilike(f"%{filters['external_field']}%")
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@@ -242,35 +58,27 @@ class EquivalencyItemService:
tenant_id: int,
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
**data.model_dump(),
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def create_nested(
db: Session,
equivalency_id: int,
data: EquivalencyItemCreate,
tenant_id: int,
company_id: int
) -> EquivalencyItem:
db_obj = EquivalencyItem(
equivalency_id=equivalency_id,
original_field=data.original_field,
external_field=data.external_field,
tenant_id=tenant_id,
company_id=company_id
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
try:
db_obj = EquivalencyItem(
original_field=data.original_field,
external_field=data.external_field,
conversion_factor=data.conversion_factor,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "uq_equivalency_item_fields" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe un item con los mismos campos '{data.original_field}''{data.external_field}'."
) from e
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e
@staticmethod
def update(
@@ -283,13 +91,18 @@ class EquivalencyItemService:
db_obj = EquivalencyItemService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
for key, value in data.model_dump(exclude_unset=True).items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
try:
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}") from e
@staticmethod
def delete(
@@ -304,3 +117,119 @@ class EquivalencyItemService:
db.delete(db_obj)
db.commit()
return True
class EquivalencyService:
"""CRUD del catálogo de equivalencias. Cada registro referencia un EquivalencyItem."""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Equivalency], int]:
query = db.query(Equivalency).filter(
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id,
)
if filters:
if "from_unit_code" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['from_unit_code']}%")
)
if "identifier" in filters:
query = query.filter(
Equivalency.identifier.ilike(f"%{filters['identifier']}%")
)
if "description" in filters:
query = query.filter(
Equivalency.description.ilike(f"%{filters['description']}%")
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> Optional[Equivalency]:
return db.query(Equivalency).filter(
Equivalency.id == id,
Equivalency.tenant_id == tenant_id,
Equivalency.company_id == company_id
).first()
@staticmethod
def create(
db: Session,
data: EquivalencyCreate,
tenant_id: int,
company_id: int
) -> Equivalency:
try:
db_obj = Equivalency(
identifier=data.identifier,
description=data.description,
item_id=data.item_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
if "uq_equivalency_identifier" in error_msg:
raise HTTPException(
status_code=400,
detail=f"Ya existe un registro para el identificador '{data.identifier}'.",
) from e
raise HTTPException(status_code=400, detail=f"Error al guardar: {error_msg}") from e
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: EquivalencyUpdate,
company_id: int
) -> Optional[Equivalency]:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
try:
update_dict = data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError as e:
db.rollback()
error_msg = str(e.orig) if hasattr(e, "orig") else str(e)
raise HTTPException(status_code=400, detail=f"Error al actualizar: {error_msg}") from e
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int
) -> bool:
db_obj = EquivalencyService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db.delete(db_obj)
db.commit()
return True

View File

@@ -14,6 +14,7 @@ from core.database import Base
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
if TYPE_CHECKING:
from .line_financials.models import LineFinancial
@@ -25,9 +26,6 @@ if TYPE_CHECKING:
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.invoices.models import InvoiceHeader
# Imported at runtime so SQLAlchemy's mapper registry can resolve the class name
# used in the relationship string below.
from api.v1.modules.a76.general_catalogs.identifiers.models import IdentifierDetail
# ============================================================================
# CORE ENTITIES
@@ -224,7 +222,7 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin):
uselist=False,
)
identifiers: Mapped[List["IdentifierDetail"]] = relationship(
"IdentifierDetail",
IdentifierDetail,
back_populates="line",
cascade="all, delete-orphan",
)

View File

@@ -1,27 +1,107 @@
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface Equivalency {
// ========================
// ITEMS (pool global)
// ========================
export interface EquivalencyItem {
id: number;
fraccion_mex: string;
fraccion_us: string;
description?: string;
original_field: string;
external_field: string;
conversion_factor: number | null;
tenant_id: number;
company_id: number;
}
export interface EquivalencyItemCreate {
original_field: string;
external_field: string;
conversion_factor?: number | null;
}
export interface EquivalencyItemUpdate {
original_field?: string;
external_field?: string;
conversion_factor?: number | null;
}
export interface EquivalencyItemListResponse {
items: EquivalencyItem[];
total: number;
page: number;
page_size: number;
pages: number;
}
export async function getEquivalencyItems(
page: number = 1,
pageSize: number = 50,
companyId: number,
filters: Record<string, any> = {}
): Promise<ApiResponse<EquivalencyItemListResponse>> {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
});
return await api.get(`/v1/a76/equivalencies/items/?${params.toString()}`);
}
export async function getEquivalencyItem(
id: number,
companyId: number
): Promise<ApiResponse<EquivalencyItem>> {
return await api.get(`/v1/a76/equivalencies/items/${id}/?company_id=${companyId}`);
}
export async function createEquivalencyItem(
data: EquivalencyItemCreate,
companyId: number
): Promise<ApiResponse<EquivalencyItem>> {
return await api.post(`/v1/a76/equivalencies/items/?company_id=${companyId}`, data);
}
export async function updateEquivalencyItem(
id: number,
data: EquivalencyItemUpdate,
companyId: number
): Promise<ApiResponse<EquivalencyItem>> {
return await api.put(`/v1/a76/equivalencies/items/${id}/?company_id=${companyId}`, data);
}
export async function deleteEquivalencyItem(
id: number,
companyId: number
): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/equivalencies/items/${id}?company_id=${companyId}`);
}
// ========================
// CATÁLOGO (referencia item_id)
// ========================
export interface Equivalency {
id: number;
identifier: string;
description: string | null;
item_id: number | null;
item: EquivalencyItem | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at?: string;
}
export interface EquivalencyCreate {
fraccion_mex: string;
fraccion_us: string;
description?: string;
identifier: string;
description?: string | null;
item_id?: number | null;
}
export interface EquivalencyUpdate {
fraccion_mex?: string;
fraccion_us?: string;
description?: string;
identifier?: string;
description?: string | null;
item_id?: number | null;
}
export interface EquivalencyListResponse {
@@ -44,29 +124,34 @@ export async function getEquivalencies(
company_id: companyId.toString(),
...filters
});
return await api.get(`/v1/a76/equivalencies/?${params.toString()}`);
}
export async function getEquivalency(id: number, companyId: number): Promise<ApiResponse<Equivalency>> {
export async function getEquivalency(
id: number,
companyId: number
): Promise<ApiResponse<Equivalency>> {
return await api.get(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`);
}
export async function createEquivalency(
data: EquivalencyCreate,
data: EquivalencyCreate,
companyId: number
): Promise<ApiResponse<Equivalency>> {
return await api.post(`/v1/a76/equivalencies/?company_id=${companyId}`, data);
}
export async function updateEquivalency(
id: number,
data: EquivalencyUpdate,
id: number,
data: EquivalencyUpdate,
companyId: number
): Promise<ApiResponse<Equivalency>> {
return await api.put(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`, data);
}
export async function deleteEquivalency(id: number, companyId: number): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/equivalencies/${id}/?company_id=${companyId}`);
export async function deleteEquivalency(
id: number,
companyId: number
): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/equivalencies/${id}?company_id=${companyId}`);
}

View File

@@ -0,0 +1,40 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { renderComponent } from '$lib/components/ui/data-table';
import CatalogDataTableActions from './catalog-data-table-actions.svelte';
export function createCatalogColumns({
onInsertItems,
onEdit,
onSuccess
}: {
onInsertItems: (equivalency: Equivalency) => void;
onEdit: (equivalency: Equivalency) => void;
onSuccess?: () => void;
}): ColumnDef<Equivalency>[] {
return [
{
accessorKey: 'identifier',
header: 'Identificador',
cell: ({ row }) => row.original.identifier || 'N/A'
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => row.original.description || ''
},
{
id: 'actions',
Header: 'Acciones',
cell: ({ row }) => {
return renderComponent(CatalogDataTableActions, {
item: row.original,
onInsertItems,
onEdit,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { companyStore } from '$lib/stores/company.svelte';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import {
createEquivalency,
updateEquivalency
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Equivalency | null;
onSuccess?: (saved?: Equivalency) => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar equivalencia' : 'Nueva equivalencia');
let formData = $state({
identifier: '',
description: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (!open) return;
if (item) {
formData = {
identifier: item.identifier || '',
description: item.description || ''
};
} else {
formData = {
identifier: '',
description: ''
};
}
error = null;
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = null;
try {
if (!formData.identifier.trim()) throw new Error('Identificador requerido');
if (formData.identifier.trim().length > 10)
throw new Error('El identificador no puede exceder 10 caracteres');
const dataToSend = {
identifier: formData.identifier.trim(),
description: formData.description.trim() ? formData.description.trim() : null
};
const response = isEdit && item
? await updateEquivalency(item.id, dataToSend, companyId)
: await createEquivalency(dataToSend, companyId);
if (response.error) throw new Error(response.error);
open = false;
// Proceso 1: si fue INSERT, encadenamos a DATOS DE EQUIVALENTES.
if (!isEdit && response.data) onSuccess?.(response.data);
else onSuccess?.();
} catch (e) {
console.error(e);
error = e instanceof Error ? e.message : 'Error al guardar la equivalencia';
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[520px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="grid gap-4 py-4"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">{error}</div>
{/if}
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="identifier">
Identificador <span class="text-destructive">*</span>
</Label>
<Input
id="identifier"
bind:value={formData.identifier}
disabled={loading}
maxlength={10}
required
placeholder="Ej: KGM, PCS, MP..."
/>
</div>
<div class="grid gap-2">
<Label for="description">Descripción</Label>
<Textarea
id="description"
bind:value={formData.description}
disabled={loading}
maxlength={200}
class="min-h-[100px]"
placeholder="(opcional)"
/>
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { deleteEquivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
let {
item,
onInsertItems,
onEdit,
onSuccess
}: {
item: Equivalency;
onInsertItems: (equivalency: Equivalency) => void;
onEdit: (equivalency: Equivalency) => void;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
async function handleDelete() {
if (!confirm('¿Está seguro de eliminar esta equivalencia?')) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
try {
const response = await deleteEquivalency(item.id, companyId);
if (response.error) throw new Error(response.error);
onSuccess?.();
} catch (err: any) {
console.error('Error deleting equivalency:', err);
alert(err?.message || 'Error al eliminar la equivalencia');
} finally {
loading = false;
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => onInsertItems(item)}>
<span>Insertar items</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onEdit(item)}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-red-600" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 class="mr-2 h-4 w-4" />
{/if}
Borrar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -1,25 +1,23 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import type { EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
import { Header } from '$lib/components/ui/alert-dialog';
export function createColumns(onSuccess?: () => void): ColumnDef<Equivalency>[] {
export function createColumns(onSuccess?: () => void): ColumnDef<EquivalencyItem>[] {
return [
{
accessorKey: 'fraccion_mex',
header: 'Fracción MX',
cell: ({ row }) => row.original.fraccion_mex || 'N/A'
accessorKey: 'original_field',
header: 'Desde código',
cell: ({ row }) => row.original.original_field || 'N/A'
},
{
accessorKey: 'fraccion_us',
header: 'Fracción US',
cell: ({ row }) => row.original.fraccion_us || 'N/A'
accessorKey: 'external_field',
header: 'Hacia código',
cell: ({ row }) => row.original.external_field || 'N/A'
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => row.original.description || 'N/A'
accessorKey: 'conversion_factor',
header: 'Factor de conversión',
cell: ({ row }) => (row.original.conversion_factor ?? 0).toString()
},
{
id: 'actions',

View File

@@ -4,59 +4,56 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { companyStore } from '$lib/stores/company.svelte';
import { Scale } from 'lucide-svelte';
import {
createEquivalency,
updateEquivalency,
type Equivalency
createEquivalencyItem,
updateEquivalencyItem,
type EquivalencyItem
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { obtenerAtajosFormularioEquivalencias } from '$lib/config/shortcuts/dashboard/general_catalogs/equivalencies/edit';
let {
open = $bindable(false),
item = null,
defaultOriginalField = null,
onSuccess
}: {
open: boolean;
item?: Equivalency | null;
item?: EquivalencyItem | null;
defaultOriginalField?: string | null;
onSuccess?: () => void;
} = $props();
// Atajos
const isEdit = $derived(!!item);
const title = $derived(
isEdit ? `Editar Equivalencia ${item?.fraccion_mex || ''}` : 'Nueva Equivalencia'
isEdit
? `Editar: ${item?.original_field || ''}${item?.external_field || ''}`
: 'Insertar detalle de equivalencia'
);
// Estado del formulario
let formData = $state({
fraccion_mex: '',
fraccion_us: '',
description: ''
original_field: '',
external_field: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Cargar datos
$effect(() => {
if (open) {
if (item) {
formData = {
fraccion_mex: item.fraccion_mex || '',
fraccion_us: item.fraccion_us || '',
description: item.description || ''
};
} else {
// Reset
formData = {
fraccion_mex: '',
fraccion_us: '',
description: ''
};
}
error = null;
if (!open) return;
if (item) {
formData = {
original_field: item.original_field || '',
external_field: item.external_field || ''
};
} else {
formData = {
original_field: defaultOriginalField ?? '',
external_field: ''
};
}
error = null;
});
async function handleSubmit() {
@@ -70,15 +67,18 @@
error = null;
try {
let response;
if (isEdit && item) {
response = await updateEquivalency(item.id, formData, companyId);
} else {
response = await createEquivalency(formData, companyId);
}
if (!formData.original_field.trim()) throw new Error('Campo original requerido');
if (!formData.external_field.trim()) throw new Error('Campo externo requerido');
if (response.error) {
throw new Error(response.error);
const dataToSend = {
original_field: formData.original_field.trim(),
external_field: formData.external_field.trim()
};
if (isEdit && item) {
await updateEquivalencyItem(item.id, dataToSend, companyId);
} else {
await createEquivalencyItem(dataToSend, companyId);
}
open = false;
@@ -111,44 +111,50 @@
</div>
{/if}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="fraccion_mex" class="text-right">Fracción MX</Label>
<Input
id="fraccion_mex"
bind:value={formData.fraccion_mex}
class="col-span-3"
maxlength={10}
required
placeholder="Ej. 8544.11.01"
/>
</div>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="from_unit_code">
Campo Original <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="from_unit_code"
bind:value={formData.original_field}
class="pl-9 font-mono"
placeholder="Ej: PZA, KGM..."
disabled={loading}
required
/>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="fraccion_us" class="text-right">Fracción US</Label>
<Input
id="fraccion_us"
bind:value={formData.fraccion_us}
class="col-span-3"
maxlength={100}
required
placeholder="Ej. 8544.11.00"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="description" class="text-right">Descripción</Label>
<Input
id="description"
bind:value={formData.description}
class="col-span-3"
maxlength={200}
/>
<div class="grid gap-2">
<Label for="to_unit_code">
Campo Exterior <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="to_unit_code"
bind:value={formData.external_field}
class="pl-9 font-mono"
placeholder="Ej: PIEZAS, KGS..."
disabled={loading}
required
/>
</div>
</div>
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : 'Guardar'}
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>

View File

@@ -0,0 +1,383 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
import { Check } from 'lucide-svelte';
import type {
Equivalency,
EquivalencyItem
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import {
getEquivalencyItems,
getEquivalencies,
createEquivalency,
deleteEquivalencyItem,
updateEquivalency
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import EquivalencyItemCreateEditDialog from './create-edit-dialog.svelte';
let {
open = $bindable(false),
equivalency = null,
mode = 'edit',
onSuccess
}: {
open: boolean;
equivalency?: Equivalency | null;
mode?: 'create' | 'edit';
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let items = $state<EquivalencyItem[]>([]);
// El item actualmente seleccionado/asociado a esta equivalencia
let selectedItemId = $state<number | null>(null);
let equivalencyDraft = $state({ identifier: '', description: '' });
let descriptionDraft = $state('');
let savedEquivalency = $state<Equivalency | null>(null);
function getEffectiveEquivalency(): Equivalency | null {
if (mode === 'edit') return equivalency ?? null;
return savedEquivalency;
}
// Modal de crear/editar item
let inputsOpen = $state(false);
let inputsItem = $state<EquivalencyItem | null>(null);
function getSelectedItem(): EquivalencyItem | null {
if (selectedItemId === null) return null;
return items.find((i) => i.id === selectedItemId) || null;
}
async function reloadItems() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
items = [];
return;
}
loading = true;
error = null;
try {
const resp = await getEquivalencyItems(1, 2000, companyId);
if (resp.error) throw new Error(resp.error);
items = resp.data.items || [];
} catch (e) {
console.error(e);
error = e instanceof Error ? e.message : 'Error al cargar items';
items = [];
} finally {
loading = false;
}
}
async function ensureEquivalencySaved(): Promise<Equivalency | null> {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('Selecciona una compañía');
return null;
}
if (mode === 'edit') {
return equivalency ?? null;
}
if (savedEquivalency) return savedEquivalency;
const identifier = equivalencyDraft.identifier.trim();
if (!identifier) {
toast.error('El identificador es requerido');
return null;
}
const description = descriptionDraft.trim() || equivalencyDraft.description.trim() || null;
const created = await createEquivalency({ identifier, description }, companyId);
if (created.data && !created.error) {
savedEquivalency = created.data;
descriptionDraft = created.data.description || '';
return savedEquivalency;
}
// Si ya existía, resolver por identifier
const listResp = await getEquivalencies(1, 50, companyId, { from_unit_code: identifier });
const existing =
(listResp.data?.items || []).find(
(e) => e.identifier?.toUpperCase() === identifier.toUpperCase()
) ?? null;
if (!existing) {
toast.error(listResp.error || 'No se pudo crear la equivalencia');
return null;
}
savedEquivalency = existing;
descriptionDraft = existing.description || '';
return savedEquivalency;
}
async function handleSave() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
if (mode === 'create') {
const identifier = equivalencyDraft.identifier.trim();
if (!identifier) {
toast.error('El identificador es requerido');
return;
}
const description = descriptionDraft.trim() || null;
if (!savedEquivalency) {
const created = await createEquivalency(
{ identifier, description, item_id: selectedItemId },
companyId
);
if (created.error || !created.data) {
toast.error(created.error || 'Error al guardar');
return;
}
savedEquivalency = created.data;
} else {
const updated = await updateEquivalency(
savedEquivalency.id,
{ identifier, description, item_id: selectedItemId },
companyId
);
if (updated.error) {
toast.error(updated.error || 'Error al actualizar');
return;
}
}
} else {
if (!equivalency) return;
const updated = await updateEquivalency(
equivalency.id,
{ description: descriptionDraft.trim() || null, item_id: selectedItemId },
companyId
);
if (updated.error) {
toast.error(updated.error || 'Error al actualizar');
return;
}
}
toast.success('Equivalencia guardada');
open = false;
onSuccess?.();
}
async function handleInsert() {
const eq = await ensureEquivalencySaved();
if (!eq) return;
inputsItem = null;
inputsOpen = true;
}
async function handleEdit() {
const selected = getSelectedItem();
if (!selected) {
toast.error('Selecciona un item para editar');
return;
}
inputsItem = selected;
inputsOpen = true;
}
async function handleDelete() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const selected = getSelectedItem();
if (!selected) {
toast.error('Selecciona un item para borrar');
return;
}
if (!confirm('¿Está seguro de eliminar este item del pool?')) return;
try {
await deleteEquivalencyItem(selected.id, companyId);
toast.success('Item eliminado');
// Si era el item asociado a esta equivalencia, desvincular
if (selectedItemId === selected.id) selectedItemId = null;
await reloadItems();
onSuccess?.();
} catch (e) {
console.error(e);
toast.error('No se pudo eliminar', { description: e instanceof Error ? e.message : '' });
}
}
$effect(() => {
if (!open) return;
const _mode = mode;
const _equivalency = equivalency;
untrack(() => {
savedEquivalency = null;
if (_mode === 'edit') {
descriptionDraft = _equivalency?.description || '';
equivalencyDraft = {
identifier: _equivalency?.identifier || '',
description: _equivalency?.description || ''
};
savedEquivalency = _equivalency ?? null;
selectedItemId = _equivalency?.item_id ?? null;
} else {
equivalencyDraft = { identifier: '', description: '' };
descriptionDraft = '';
selectedItemId = null;
}
reloadItems();
});
});
function handleInputsSuccess() {
inputsOpen = false;
reloadItems();
onSuccess?.();
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px]">
<Dialog.Header>
<Dialog.Title>DATOS DE EQUIVALENTES</Dialog.Title>
<Dialog.Description>
Selecciona un item del pool para asociarlo a esta equivalencia.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-3 gap-4">
<div class="grid gap-2">
<Label>Identificador</Label>
{#if mode === 'edit'}
<Input value={equivalency?.identifier || ''} readonly />
{:else if savedEquivalency}
<Input value={savedEquivalency.identifier || ''} readonly />
{:else}
<Input
bind:value={equivalencyDraft.identifier}
placeholder="Ej: PZA, KGS, MP..."
/>
{/if}
</div>
<div class="grid gap-2 col-span-2">
<Label>Descripción</Label>
<Input bind:value={descriptionDraft} placeholder="(opcional)" />
</div>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row class="bg-muted/20">
<Table.Head class="w-10"></Table.Head>
<Table.Head>Campo Original</Table.Head>
<Table.Head>Campo Externo</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center">Cargando...</Table.Cell>
</Table.Row>
{:else if error}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center text-destructive">
{error}
</Table.Cell>
</Table.Row>
{:else if items.length === 0}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center text-muted-foreground">
No hay items en el pool. Usa "Insertar" para crear uno.
</Table.Cell>
</Table.Row>
{:else}
{#each items as item}
{@const isSelected = item.id === selectedItemId}
<Table.Row
class={[
'cursor-pointer transition-colors',
isSelected
? 'bg-primary/10 font-medium'
: 'hover:bg-muted/50 text-muted-foreground'
].join(' ')}
onclick={() => (selectedItemId = isSelected ? null : item.id)}
>
<Table.Cell class="w-10 text-center">
{#if isSelected}
<Check class="h-4 w-4 text-primary mx-auto" />
{/if}
</Table.Cell>
<Table.Cell class="font-mono">{item.original_field}</Table.Cell>
<Table.Cell class="font-mono">{item.external_field}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="flex justify-between items-center">
<p class="text-xs text-muted-foreground">
{#if selectedItemId}
Item seleccionado: <span class="font-mono font-medium">
{items.find((i) => i.id === selectedItemId)?.original_field ?? ''}
{items.find((i) => i.id === selectedItemId)?.external_field ?? ''}
</span>
{:else}
Ningún item seleccionado para esta equivalencia.
{/if}
</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={handleInsert}>Insertar</Button>
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={selectedItemId === null}
>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedItemId === null}
>
Borrar
</Button>
</div>
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (open = false)}>Cerrar</Button>
<Button type="button" onclick={handleSave}>Guardar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<EquivalencyItemCreateEditDialog
bind:open={inputsOpen}
item={inputsItem}
defaultOriginalField={inputsItem ? null : null}
onSuccess={handleInputsSuccess}
/>

View File

@@ -2,8 +2,8 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { deleteEquivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import type { EquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { deleteEquivalencyItem } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
@@ -11,14 +11,14 @@
item,
onSuccess
}: {
item: Equivalency;
item: EquivalencyItem;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Equivalency | null>(null);
let selectedItem = $state<EquivalencyItem | null>(null);
async function handleDelete() {
if (!confirm('¿Está seguro de eliminar esta equivalencia?')) {
@@ -33,7 +33,7 @@
loading = true;
try {
const response = await deleteEquivalency(item.id, companyId);
const response = await deleteEquivalencyItem(item.id, companyId);
if (response.error) {
throw new Error(response.error);

View File

@@ -24,11 +24,9 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
}
const filters: Record<string, string> = {};
const identifier = url.searchParams.get('identifier');
const description = url.searchParams.get('description');
if (identifier) filters.identifier = identifier;
if (description) filters.description = description;
// El backend usa `from_unit_code` para filtrar por `Equivalency.identifier`
const fromUnitCode = url.searchParams.get('from_unit_code');
if (fromUnitCode) filters.from_unit_code = fromUnitCode;
const queryParams = new URLSearchParams({
page: page.toString(),
@@ -37,7 +35,12 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
...filters
});
const response = await authenticatedFetch(`v1/a76/equivalencies/?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch);
const response = await authenticatedFetch(
`v1/a76/equivalencies/?${queryParams.toString()}`,
{ method: 'GET' },
cookies,
fetch
);
if (!response.ok) {
return { error: 'Failed to load', equivalencies: { items: [], total: 0, page, page_size: pageSize, pages: 0 } };

View File

@@ -2,32 +2,38 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/general_catalogs/equivalencies/columns';
import CreateEditDialog from '$lib/components/dashboard/general_catalogs/equivalencies/create-edit-dialog.svelte';
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import { createCatalogColumns } from '$lib/components/dashboard/general_catalogs/equivalencies/catalog-columns';
import DataTable from '$lib/components/dashboard/general_catalogs/equivalencies/data-table.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaEquivalencias } from '$lib/config/shortcuts/dashboard/general_catalogs/equivalencies/list';
import DataEquivalenciesDialog from '$lib/components/dashboard/general_catalogs/equivalencies/data-equivalencies-dialog.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let createDialogOpen = $state(false);
let dataDialogOpen = $state(false);
let selectedEquivalency = $state<Equivalency | null>(null);
let dataMode = $state<'create' | 'edit'>('edit');
// Atajos
useShortcuts(
'Lista Equivalencias',
obtenerAtajosListaEquivalencias({
manejarNuevo: () => (createDialogOpen = true),
manejarNuevo: () => {
selectedEquivalency = null;
dataMode = 'create';
dataDialogOpen = true;
},
manejarActualizar: handleSuccess
})
);
// Filtros
let searchFraccion = $state($page.url.searchParams.get('fraccion_mex') || '');
let searchDesc = $state($page.url.searchParams.get('description') || '');
let searchFrom = $state($page.url.searchParams.get('from_unit_code') || '');
let timeout: ReturnType<typeof setTimeout>;
function handleSearch() {
@@ -35,11 +41,8 @@
clearTimeout(timeout);
timeout = setTimeout(() => {
const url = new URL($page.url);
if (searchFraccion) url.searchParams.set('fraccion_mex', searchFraccion);
else url.searchParams.delete('fraccion_mex');
if (searchDesc) url.searchParams.set('description', searchDesc);
else url.searchParams.delete('description');
if (searchFrom) url.searchParams.set('from_unit_code', searchFrom);
else url.searchParams.delete('from_unit_code');
url.searchParams.set('page', '1');
goto(url, { keepFocus: true, noScroll: true });
@@ -50,6 +53,18 @@
const url = new URL($page.url);
goto(url, { invalidateAll: true });
}
function handleOpenInsertItems(equivalency: Equivalency) {
selectedEquivalency = equivalency;
dataMode = 'edit';
dataDialogOpen = true;
}
function handleOpenEditCatalog(equivalency: Equivalency) {
selectedEquivalency = equivalency;
dataMode = 'edit';
dataDialogOpen = true;
}
</script>
<div class="flex flex-col gap-4 p-4">
@@ -58,7 +73,13 @@
<h1 class="text-2xl font-bold tracking-tight">Equivalencias</h1>
<p class="text-muted-foreground">Catálogo de equivalencias</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Button
onclick={() => {
selectedEquivalency = null;
dataMode = 'create';
dataDialogOpen = true;
}}
>
<Plus class="mr-2 h-4 w-4" />
Nueva Equivalencia
</Button>
@@ -67,28 +88,28 @@
<div class="flex gap-4 items-end">
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input
placeholder="Buscar por fracción MX..."
bind:value={searchFraccion}
oninput={handleSearch}
/>
</div>
<div class="grid w-full max-w-sm items-center gap-1.5">
<Input
placeholder="Buscar por descripción..."
bind:value={searchDesc}
placeholder="Buscar por código origen..."
bind:value={searchFrom}
oninput={handleSearch}
/>
</div>
</div>
<div class="rounded-md border">
<DataTable
data={data.equivalencies.items}
columns={createColumns(handleSuccess)}
pageCount={data.equivalencies.pages}
totalItems={data.equivalencies.total}
/>
</div>
<DataTable
data={data.equivalencies.items}
columns={createCatalogColumns({
onInsertItems: handleOpenInsertItems,
onEdit: handleOpenEditCatalog,
onSuccess: handleSuccess
})}
pageCount={data.equivalencies.pages}
totalItems={data.equivalencies.total}
/>
<CreateEditDialog bind:open={createDialogOpen} onSuccess={handleSuccess} />
<DataEquivalenciesDialog
bind:open={dataDialogOpen}
equivalency={selectedEquivalency}
mode={dataMode}
onSuccess={handleSuccess}
/>
</div>