feat: Introduce a new TariffFractionSelector component, refactor tariff fraction handling across frontend forms and backend services, and add a company amendment database migration.

This commit is contained in:
Galindo97
2026-02-16 11:37:06 -06:00
parent 437bd54b13
commit 79a9f72741
15 changed files with 611 additions and 632 deletions

View File

@@ -48,6 +48,9 @@ class TariffFractionResponseDTO(BaseModel):
umt: Optional[str] = None
adv_impo: Optional[str] = None
adv_expo: Optional[str] = None
dof: Optional[str] = None
aplica_ieps: Optional[str] = None
um_code: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -38,7 +38,11 @@ async def list_tariff_fractions(
if search:
filters["search"] = search
items, total = TariffFractionService.get_all(
# Updated to async call with Sitar integration
# WARNING: Using async def with blocking DB dependency (Session) run in threadpool by FastAPI.
# Service.get_all calls Sitar (async) or DB (sync).
# This should be fine.
items, total = await TariffFractionService.get_all(
db, skip, page_size, filters
)
@@ -55,7 +59,7 @@ async def list_tariff_fractions(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Get Tariff Fraction by ID",
description="Get a specific tariff fraction by ID",
description="Get a specific tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_tariff_fraction(
tariff_fraction_id: int,
@@ -68,55 +72,3 @@ async def get_tariff_fraction(
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.post(
"/",
response_model=TariffFractionResponseDTO,
summary="Create Tariff Fraction",
description="Create a new tariff fraction (admin only)",
status_code=201,
)
async def create_tariff_fraction(
data: TariffFractionCreateDTO,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.create(db, data)
return TariffFractionResponseDTO.model_validate(item)
@router.put(
"/{tariff_fraction_id}",
response_model=TariffFractionResponseDTO,
summary="Update Tariff Fraction",
description="Update an existing tariff fraction (admin only)",
)
async def update_tariff_fraction(
tariff_fraction_id: int,
data: TariffFractionUpdateDTO,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
item = TariffFractionService.update(db, tariff_fraction_id, data)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")
return TariffFractionResponseDTO.model_validate(item)
@router.delete(
"/{tariff_fraction_id}",
summary="Delete Tariff Fraction",
description="Delete a tariff fraction (admin only)",
status_code=204,
)
async def delete_tariff_fraction(
tariff_fraction_id: int,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
success = TariffFractionService.delete(db, tariff_fraction_id)
if not success:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Tariff fraction not found")

View File

@@ -7,26 +7,165 @@ from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import zlib
import logging
from .models import TariffFraction
from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones.service import FraccionesService
from api.v1.modules.sitar.fracciones.schemas import FraccionesResponse
logger = logging.getLogger(__name__)
class TariffFractionMapper:
"""Helper to map Sitar responses to Local domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesResponse) -> TariffFraction:
# Generate ID: Use SYSID if available, else composite hash of code + nico
if fraccion.SYSID:
fake_id = fraccion.SYSID
else:
# Composite key for uniqueness if SYSID missing
unique_str = f"{fraccion.FRACCION}-{fraccion.NICO}"
fake_id = zlib.crc32(unique_str.encode('utf-8'))
# UX Enhauncement: Sitar API returns empty strings for some fields.
# We fill them with fallbacks so the frontend table isn't 90% empty.
code_val = fraccion.FRACCION
# Formatting Logic: if FRACCIONPUNTO is empty, try to format code_val
formatted_fraction = code_val
if fraccion.FRACCIONPUNTO:
formatted_fraction = fraccion.FRACCIONPUNTO
elif code_val and code_val.isdigit() and len(code_val) == 8:
# Standard 8 digit format: XX.XX.XX.XX
formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:6]}.{code_val[6:]}"
elif code_val and code_val.isdigit() and len(code_val) == 6:
# 6 digit (subheading): XX.XX.XX
formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:]}"
fraction_val = formatted_fraction
description_val = fraccion.DESCRIPCION if fraccion.DESCRIPCION else "(Sin descripción)"
tf = TariffFraction(
id=fake_id,
code=code_val,
fraction=fraction_val,
description=description_val,
nico=fraccion.NICO,
# MAP CHANGE: UMT now maps to Abbreviation (e.g., Pza, Kg)
umt=fraccion.UMABREVIACION,
adv_impo=fraccion.ADVIMPOTXT,
adv_expo=fraccion.ADVEXPOTXT
)
# Dynamically attach non-model attributes for DTO
tf.dof = fraccion.DOF
tf.aplica_ieps = fraccion.APLICAIEPS
# MAP CHANGE: New field for the numeric code (e.g., 01, 06)
tf.um_code = fraccion.UMCLAVE
return tf
class TariffFractionService:
"""Service para gestionar fracciones arancelarias (catálogo global)"""
@staticmethod
def get_all(
async def get_all(
db: Session,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[TariffFraction], int]:
"""Obtiene todas las fracciones arancelarias con filtros opcionales"""
"""
Obtiene fracciones arancelarias.
Estrategia: Sitar API -> Fallback Local DB
"""
# 1. Try Sitar API
try:
sitar_service = FraccionesService.get_instance()
# Map filters
sitar_fraccion = None
sitar_nico = None
has_filters = False
if filters:
if filters.get("search"):
term = filters["search"]
# Heuristic: if search starts with digit (after removing dots), treat as code/fraccion/nico
# This covers "0101", "01.01", "020691A"
clean_term = term.replace(".", "")
if clean_term and clean_term[0].isdigit():
sitar_fraccion = clean_term
has_filters = True
else:
# Attempt description search via API first
logger.info(f"Search term '{term}' identified as text. Attempting API description search.")
pass
if filters.get("code"):
sitar_fraccion = filters["code"]
has_filters = True
if filters.get("fraction"):
sitar_fraccion = filters["fraction"]
has_filters = True
if filters.get("nico"):
sitar_nico = filters["nico"]
has_filters = True
# Determine description filter
sitar_description = None
# Only use description if we didn't use it as code above
if filters and filters.get("search"):
clean_term = filters["search"].replace(".", "")
if not (clean_term and clean_term[0].isdigit()):
sitar_description = filters["search"]
has_filters = True
# Note: Sitar search might not return total count.
# We fetch page items. Pagination might be tricky if Sitar doesn't return total.
# Assuming Sitar returns a list.
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
nico=sitar_nico,
description=sitar_description,
nivel=5, # User requested filtering by level 5
skip=skip,
limit=limit
)
# STRICT API USAGE:
# We do NOT fallback to local DB on empty list, as user requested strict API consumption.
# We also do NOT attempt enrichment as codes mismatch (API uses '010191A' vs Local '01012101').
# Map items
items = [TariffFractionMapper.to_domain(item) for item in sitar_items]
# Estimate total (Sitar service doesn't return total currently)
# If we got full limit, assume there are more.
total = len(items) + skip
if len(items) == limit:
total += 1 # Indicate more pages
return items, total
except Exception as e:
logger.error(f"Error fetching from Sitar API: {e}")
# STRICT API USAGE: Propagate error, do NOT fallback to local DB.
raise e
@staticmethod
async def _get_all_local_async(
db: Session,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[TariffFraction], int]:
"""Lógica original de consulta local"""
query = db.query(TariffFraction)
# Aplicar filtros
@@ -64,21 +203,24 @@ class TariffFractionService:
db: Session,
tariff_fraction_id: int,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por ID"""
"""
Obtiene por ID.
Como Sitar no usa estos IDs, consultamos Local DB directamente para compatibilidad legacy.
Si se necesitara obtener detalle de Sitar, se requeriría otro identificador (Code).
"""
return (
db.query(TariffFraction)
.filter(TariffFraction.id == tariff_fraction_id)
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY (Optional: Remove or Keep for Fallback Maintenance)
@staticmethod
def get_by_code(
db: Session,
code: str,
) -> Optional[TariffFraction]:
"""Obtiene una fracción arancelaria por código"""
return (
db.query(TariffFraction)
.filter(TariffFraction.code == code)
@@ -90,8 +232,6 @@ class TariffFractionService:
db: Session,
tariff_fraction_data: TariffFractionCreateDTO,
) -> TariffFraction:
"""Crea una nueva fracción arancelaria"""
try:
tariff_fraction = TariffFraction(
**tariff_fraction_data.model_dump(),
@@ -114,8 +254,6 @@ class TariffFractionService:
tariff_fraction_id: int,
tariff_fraction_data: TariffFractionUpdateDTO,
) -> Optional[TariffFraction]:
"""Actualiza una fracción arancelaria existente"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_id
)
@@ -144,8 +282,6 @@ class TariffFractionService:
db: Session,
tariff_fraction_id: int,
) -> bool:
"""Elimina una fracción arancelaria"""
tariff_fraction = TariffFractionService.get_by_id(
db, tariff_fraction_id
)

View File

@@ -17,21 +17,8 @@ from .dto import (
)
from .service import USTariffFractionService
# Create base router with generic CRUD routes (disabled list because we'll create a custom one)
base_router = TenantCRUDRoutes(
service=USTariffFractionService,
create_schema=USTariffFractionCreateDTO,
update_schema=USTariffFractionUpdateDTO,
response_schema=USTariffFractionResponseDTO,
prefix="/us-tariff-fractions",
tags=["a76 / general catalogs / us tariff fractions"],
resource_name="USTariffFraction",
id_name="us_tariff_fraction_id",
enable_list=False, # Disable default list, we'll add custom one
enable_filters=False,
default_page_size=50,
max_page_size=10000,
)
# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar
# Writes are disabled at API level, but Service still supports fallback writes if needed internally
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
@@ -57,7 +44,8 @@ async def list_us_tariff_fractions(
if search:
filters["search"] = search
items, total = USTariffFractionService.get_all(
# Updated to async call with Sitar integration
items, total = await USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
@@ -69,5 +57,23 @@ async def list_us_tariff_fractions(
"pages": (total + page_size - 1) // page_size,
}
# Include other CRUD routes from base router
router.include_router(base_router.router)
@router.get(
"/{us_tariff_fraction_id}",
response_model=USTariffFractionResponseDTO,
summary="Get US Tariff Fraction by ID",
description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_us_tariff_fraction(
us_tariff_fraction_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)
item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return USTariffFractionResponseDTO.model_validate(item)

View File

@@ -6,19 +6,68 @@ from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import zlib
import logging
import re
from decimal import Decimal
from .models import USTariffFraction
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class USTariffFractionMapper:
"""Helper to map Sitar USA responses to Local domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction:
# Generate a deterministic numeric ID based on the unique code
# We use CRC32 to get a consistent integer implementation-independent
fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8'))
# Parse numeric values safely
ad_valorem = None
if fraccion.TARIFA1:
try:
# Extract numbers from string like "5.2%" or similar if present
# Assuming TARIFA1 might be clean number or percentage string
clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1))
if clean_val:
ad_valorem = Decimal(clean_val)
except:
pass
fixed_cost = None
if fraccion.ESPECIFICO:
try:
clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO))
if clean_val:
fixed_cost = Decimal(clean_val)
except:
pass
return USTariffFraction(
id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash
tenant_id=tenant_id,
company_id=company_id,
code=fraccion.FRACCION_SIN_PUNTO or "",
prefix=None, # Not mapped from Sitar response currently
type_code=None,
ad_valorem=ad_valorem,
fixed_cost=fixed_cost,
unit_of_measure=fraccion.UNIDADCANTIDAD,
description=fraccion.DESCRIPCION
)
class USTariffFractionService:
"""Service para gestionar fracciones arancelarias americanas"""
@staticmethod
def get_all(
async def get_all(
db: Session,
tenant_id: int,
company_id: int,
@@ -26,8 +75,60 @@ class USTariffFractionService:
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""Obtiene todas las fracciones arancelarias americanas con filtros opcionales"""
"""
Obtiene todas las fracciones arancelarias americanas con filtros opcionales.
Estrategia: Sitar API -> Fallback Local DB
"""
# 1. Try Sitar API
try:
sitar_service = FraccionesUSAService.get_instance()
sitar_fraccion = None
has_filters = False
if filters and filters.get("search"):
term = filters["search"]
# Sitar only filters by fraction code
if term.replace(".", "").isdigit():
sitar_fraccion = term
has_filters = True
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
skip=skip,
limit=limit
)
# If Sitar returns empty list AND we didn't have specific filters, attempt fallback
if not sitar_items and not has_filters:
logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
# Map items
items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items]
# Estimate total
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
logger.error(f"Error fetching USA Fractions from Sitar API, falling back to local DB: {e}")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
@staticmethod
def _get_all_local(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""Lógica original de consulta local"""
query = db.query(USTariffFraction).filter(
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
@@ -53,7 +154,10 @@ class USTariffFractionService:
def get_by_id(
db: Session, tenant_id: int, company_id: int, fraction_id: int
) -> Optional[USTariffFraction]:
"""Obtiene una fracción arancelaria americana por ID"""
"""
Obtiene por ID.
Legacy: Consulta Local DB.
"""
return (
db.query(USTariffFraction)
.filter(
@@ -64,6 +168,8 @@ class USTariffFractionService:
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY
@staticmethod
def create(
db: Session,
@@ -71,7 +177,6 @@ class USTariffFractionService:
company_id: int,
fraction_data: USTariffFractionCreateDTO,
) -> USTariffFraction:
"""Crea una nueva fracción arancelaria americana"""
try:
db_fraction = USTariffFraction(
tenant_id=tenant_id,
@@ -98,7 +203,6 @@ class USTariffFractionService:
fraction_id: int,
fraction_data: USTariffFractionUpdateDTO,
) -> Optional[USTariffFraction]:
"""Actualiza una fracción arancelaria americana existente"""
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
)
@@ -117,7 +221,6 @@ class USTariffFractionService:
def delete(
db: Session, tenant_id: int, company_id: int, fraction_id: int
) -> bool:
"""Elimina una fracción arancelaria americana"""
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
)

View File

@@ -22,7 +22,7 @@ class SitarAPIBaseService:
self.base_url = os.getenv("SITAR_API_URL")
self.username = os.getenv("SITAR_API_USER")
self.password = os.getenv("SITAR_API_PASSWORD")
self.timeout = 10.0
self.timeout = 30.0
if not all([self.base_url, self.username, self.password]):
raise ValueError(
@@ -101,4 +101,20 @@ class SitarAPIBaseService:
headers=headers,
)
response.raise_for_status()
# DEBUG LOGGING for SITAR inspection
if "fracciones" in url:
import logging
logger = logging.getLogger(__name__)
logger.info(f"SITAR API Response Headers for {url}: {dict(response.headers)}")
try:
data = response.json()
if isinstance(data, dict):
logger.info(f"SITAR API Response Body Keys: {list(data.keys())}")
elif isinstance(data, list) and len(data) > 0:
logger.info(f"SITAR API Response List Item Keys: {list(data[0].keys())}")
return data
except Exception:
pass
return response.json()

View File

@@ -33,6 +33,7 @@ class FraccionesResponse(BaseModel):
APLICAIEPS: Optional[str] = Field(None, max_length=1)
NIVEL: Optional[int] = None
NICO: Optional[str] = Field(None, max_length=14)
SYSID: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -21,6 +21,8 @@ class FraccionesService(SitarAPIBaseService):
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
description: Optional[str] = None,
nivel: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
@@ -30,6 +32,10 @@ class FraccionesService(SitarAPIBaseService):
params["fraccion"] = fraccion
if nico:
params["nico"] = nico
if description:
params["descripcion"] = description
if nivel is not None:
params["nivel"] = nivel
data = await self._make_request("GET", "/api/v1/fracciones/", params=params)
return [FraccionesResponse(**item) for item in data]

View File

@@ -11,8 +11,10 @@ export interface TariffFraction {
umt: string | null;
adv_impo: string | null;
adv_expo: string | null;
created_at: string | null;
updated_at: string | null;
dof: string | null;
aplica_ieps: string | null;
um_code: string | null;
}
export interface TariffFractionCreate {

View File

@@ -143,16 +143,30 @@
let showUnitDialog = $state(false);
let searchUnit = $state('');
import TariffFractionSelector from '$lib/components/dashboard/goods/modales/TariffFractionSelector.svelte';
let showFractionDialog = $state(false);
let tariffFractions = $state<TariffFraction[]>([]);
let searchFraction = $state('');
let currentPage = $state(1);
let totalFractions = $state(0);
let hasMoreFractions = $state(true);
let isLoadingFractions = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
// Removing inline tariff fraction state
// let tariffFractions = ...
function openFractionSearch() {
showFractionDialog = true;
}
let showUSFractionDialog = $state(false);
// ...
// loadFractions removed
function selectFraction(fraction: TariffFraction) {
formData.fraction = fraction.fraction;
formData.fraction_umt = (fraction.umt ?? '') as string;
formData.fraction_uma_key = (fraction.nico ?? '') as string;
// Actualizar tarifa de importación
formData.import_tariff_code = fraction.fraction || '';
formData.import_tariff_type = (fraction.umt ?? '') as string;
showFractionDialog = false;
}
let usTariffFractions = $state<USTariffFraction[]>([]);
let searchUSFraction = $state('');
let currentUSPage = $state(1);
@@ -262,56 +276,6 @@
searchUnit = '';
}
async function openFractionSearch() {
showFractionDialog = true;
searchFraction = '';
tariffFractions = [];
currentPage = 1;
totalFractions = 0;
hasMoreFractions = true;
await loadFractions('', 1);
}
async function loadFractions(search: string, page: number = currentPage) {
const companyId = companyStore.activeCompany?.id;
if (!companyId || isLoadingFractions) return;
isLoadingFractions = true;
try {
const filters = search ? { search } : {};
const pageSize = 100;
const response = await getTariffFractions(page, pageSize, companyId, filters);
if (response.data) {
if (page === 1) {
tariffFractions = [...response.data.items];
} else {
tariffFractions = [...tariffFractions, ...response.data.items];
}
totalFractions = response.data.total;
currentPage = page;
hasMoreFractions = tariffFractions.length < response.data.total;
}
} catch (error) {
console.error('Error cargando fracciones arancelarias:', error);
} finally {
isLoadingFractions = false;
}
}
function selectFraction(fraction: TariffFraction) {
formData.fraction = fraction.fraction;
formData.fraction_umt = (fraction.umt ?? '') as string;
formData.fraction_uma_key = (fraction.nico ?? '') as string;
// Actualizar tarifa de importación
formData.import_tariff_code = fraction.fraction || '';
formData.import_tariff_type = (fraction.umt ?? '') as string;
showFractionDialog = false;
searchFraction = '';
}
async function openUSFractionSearch() {
showUSFractionDialog = true;
searchUSFraction = '';
@@ -565,7 +529,7 @@
<div class="space-y-3">
<!-- Clase y Requiere Revisión Física -->
<div class="flex gap-4 items-end">
<div class="flex items-end gap-4">
<div class="flex-1 space-y-2">
<Label for="class_code" class="font-bold">
Clase: <span class="text-red-500">*</span>
@@ -588,7 +552,7 @@
onblur={() => validateField('class_code')}
/>
{#if validationErrors.class_code}
<p class="text-sm text-red-500 mt-1">{validationErrors.class_code}</p>
<p class="mt-1 text-sm text-red-500">{validationErrors.class_code}</p>
{/if}
</div>
@@ -596,7 +560,7 @@
<Label for="material_key" class="font-bold">
Tipo de Activo Fijo: <span class="text-red-500">*</span>
</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="material_key"
bind:value={formData.material_key}
@@ -610,12 +574,12 @@
<Button type="button" variant="outline" size="icon" onclick={openMaterialSearch}>
<Folder class="h-4 w-4" />
</Button>
<span class="text-blue-600 hover:underline cursor-pointer text-sm">
<span class="cursor-pointer text-sm text-blue-600 hover:underline">
{formData.material_description || ''}
</span>
</div>
{#if validationErrors.material_key}
<p class="text-sm text-red-500 mt-1">{validationErrors.material_key}</p>
<p class="mt-1 text-sm text-red-500">{validationErrors.material_key}</p>
{/if}
</div>
</div>
@@ -635,7 +599,7 @@
onblur={() => validateField('description_es')}
/>
{#if validationErrors.description_es}
<p class="text-sm text-red-500 mt-1">{validationErrors.description_es}</p>
<p class="mt-1 text-sm text-red-500">{validationErrors.description_es}</p>
{/if}
</div>
@@ -651,12 +615,12 @@
</div>
<!-- U.M. Comercial y Fracción Arancelaria -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="unit_of_measure" class="font-bold">
U.M. Comercial: <span class="text-red-500">*</span>
</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="unit_of_measure"
bind:value={formData.unit_of_measure}
@@ -670,12 +634,12 @@
<Button type="button" variant="outline" size="icon" onclick={openUnitOfMeasureSearch}>
<Folder class="h-4 w-4" />
</Button>
<span class="text-blue-600 hover:underline cursor-pointer text-sm">
<span class="cursor-pointer text-sm text-blue-600 hover:underline">
{formData.unit_of_measure_description || ''}
</span>
</div>
{#if validationErrors.unit_of_measure}
<p class="text-sm text-red-500 mt-1">{validationErrors.unit_of_measure}</p>
<p class="mt-1 text-sm text-red-500">{validationErrors.unit_of_measure}</p>
{/if}
</div>
@@ -683,7 +647,7 @@
<Label for="fraction" class="font-bold">
Fracción Arancelaria: <span class="text-red-500">*</span>
</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="fraction"
bind:value={formData.fraction}
@@ -699,16 +663,16 @@
</Button>
</div>
{#if validationErrors.fraction}
<p class="text-sm text-red-500 mt-1">{validationErrors.fraction}</p>
<p class="mt-1 text-sm text-red-500">{validationErrors.fraction}</p>
{/if}
</div>
</div>
<!-- Fracción Americana y Tasa Depreciación -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="us_fraction">Fracción Americana:</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="us_fraction"
bind:value={formData.us_fraction}
@@ -724,7 +688,7 @@
<div class="space-y-2">
<Label for="annual_depreciation_rate">Tasa Anual de Depreciación:</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="annual_depreciation_rate"
type="number"
@@ -742,10 +706,10 @@
</div>
<!-- FDA, Carta Porte y ECCN -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label for="fda_key">Clave FDA:</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input id="fda_key" bind:value={formData.fda_key} placeholder="Clave FDA" class="flex-1" />
<Button type="button" variant="outline" size="icon" onclick={openFDASearch}>
<Folder class="h-4 w-4" />
@@ -755,7 +719,7 @@
<div class="space-y-2">
<Label for="carta_porte_code">Carta Porte:</Label>
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Input
id="carta_porte_code"
bind:value={formData.carta_porte_code}
@@ -800,9 +764,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-96 overflow-auto">
<div class="max-h-96 overflow-auto rounded-md border">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold">Clave</th>
<th class="px-4 py-2 text-left font-semibold">Descripción</th>
@@ -811,7 +775,7 @@
<tbody>
{#each filteredMaterialTypes as material (material.key)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectMaterial(material)}
>
<td class="px-4 py-2">{material.key}</td>
@@ -844,9 +808,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-96 overflow-auto">
<div class="max-h-96 overflow-auto rounded-md border">
<table class="w-full text-sm">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-3 py-2 text-left font-semibold">Código</th>
<th class="px-3 py-2 text-left font-semibold">Descripción</th>
@@ -857,7 +821,7 @@
<tbody>
{#each filteredUnits as unit (unit.code)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectUnit(unit)}
>
<td class="px-3 py-2">{unit.code}</td>
@@ -877,66 +841,11 @@
</Dialog.Root>
<!-- Dialog: Buscar fracción arancelaria -->
<Dialog.Root bind:open={showFractionDialog}>
<Dialog.Content class="max-w-4xl w-full">
<Dialog.Header>
<Dialog.Title>CATALOGO DE FRACCIONES SITAR - SCAII</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div>
<Label for="search_fraction">Buscando:</Label>
<Input
id="search_fraction"
bind:value={searchFraction}
placeholder="Buscar por fracción, descripción, NICO..."
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-[600px] overflow-y-auto overflow-x-auto">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<tr>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Fracción</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">NICO</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Descripción</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">U.M.T</th>
</tr>
</thead>
<tbody>
{#each tariffFractions as fraction (fraction.code)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
onclick={() => selectFraction(fraction)}
>
<td class="px-4 py-2 whitespace-nowrap">{fraction.fraction}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.nico}</td>
<td class="px-4 py-2">{fraction.description}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.umt}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-muted-foreground">
{#if isLoadingFractions}
Cargando fracciones...
{:else}
No hay fracciones disponibles
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showFractionDialog = false)}>Cerrar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<TariffFractionSelector bind:open={showFractionDialog} onSelect={selectFraction} />
<!-- Dialog: Buscar fracción americana -->
<Dialog.Root bind:open={showUSFractionDialog}>
<Dialog.Content class="max-w-4xl w-full">
<Dialog.Content class="w-full max-w-4xl">
<Dialog.Header>
<Dialog.Title>CATALOGO DE FRACCIONES AMERICANAS</Dialog.Title>
</Dialog.Header>
@@ -950,9 +859,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-[600px] overflow-y-auto overflow-x-auto">
<div class="max-h-[600px] overflow-x-auto overflow-y-auto rounded-md border">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Código</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Prefijo</th>
@@ -964,7 +873,7 @@
<tbody>
{#each usTariffFractions as fraction (fraction.id)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectUSFraction(fraction)}
>
<td class="px-4 py-2 whitespace-nowrap">{fraction.code}</td>
@@ -996,7 +905,7 @@
<!-- Dialog: Buscar depreciación -->
<Dialog.Root bind:open={showDepreciationDialog}>
<Dialog.Content class="max-w-4xl w-full">
<Dialog.Content class="w-full max-w-4xl">
<Dialog.Header>
<Dialog.Title>CATALOGO DE DEPRECIACION</Dialog.Title>
</Dialog.Header>
@@ -1010,9 +919,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-[600px] overflow-y-auto overflow-x-auto">
<div class="max-h-[600px] overflow-x-auto overflow-y-auto rounded-md border">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold">Fracción</th>
<th class="px-4 py-2 text-left font-semibold">Descripción</th>
@@ -1022,7 +931,7 @@
<tbody>
{#each depreciationCatalog as item (item.id)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectDepreciation(item)}
>
<td class="px-4 py-2">{item.fraction}</td>
@@ -1052,7 +961,7 @@
<!-- Dialog: Buscar FDA -->
<Dialog.Root bind:open={showFDADialog}>
<Dialog.Content class="max-w-4xl w-full">
<Dialog.Content class="w-full max-w-4xl">
<Dialog.Header>
<Dialog.Title>CATALOGO FDA</Dialog.Title>
</Dialog.Header>
@@ -1066,9 +975,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-[600px] overflow-y-auto overflow-x-auto">
<div class="max-h-[600px] overflow-x-auto overflow-y-auto rounded-md border">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold">Clave FDA</th>
<th class="px-4 py-2 text-left font-semibold">Descripción</th>
@@ -1077,7 +986,7 @@
<tbody>
{#each fdaCatalog as item (item.id)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => selectFDA(item)}
>
<td class="px-4 py-2">{item.fda_key}</td>
@@ -1106,7 +1015,7 @@
<!-- Dialog: Buscar Carta Porte -->
<Dialog.Root bind:open={showCartaPorteDialog}>
<Dialog.Content class="max-w-4xl w-full">
<Dialog.Content class="w-full max-w-4xl">
<Dialog.Header>
<Dialog.Title>CATALOGO DE CARTA PORTE</Dialog.Title>
</Dialog.Header>
@@ -1120,9 +1029,9 @@
class="mt-1"
/>
</div>
<div class="border rounded-md max-h-[600px] overflow-y-auto overflow-x-auto">
<div class="max-h-[600px] overflow-x-auto overflow-y-auto rounded-md border">
<table class="w-full">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold">Código</th>
<th class="px-4 py-2 text-left font-semibold">Descripción</th>
@@ -1131,7 +1040,7 @@
<tbody>
{#each cartaPorteCatalog as item (item.id)}
<tr
class="border-b hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => {
formData.carta_porte_code = item.code;
showCartaPorteDialog = false;

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Loader2, Search } from 'lucide-svelte';
import {
getTariffFractions,
type TariffFraction
} from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
onSelect
}: { open: boolean; onSelect: (fraction: TariffFraction) => void } = $props();
let tariffFractions = $state<TariffFraction[]>([]);
let searchFraction = $state('');
let currentPage = $state(1);
let totalFractions = $state(0);
let hasMoreFractions = $state(true);
let isLoadingFractions = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
// Cargar datos al abrir
$effect(() => {
if (open) {
// Reiniciar estado al abrir
if (tariffFractions.length === 0) {
searchFraction = '';
currentPage = 1;
totalFractions = 0;
hasMoreFractions = true;
loadFractions('', 1);
}
}
});
async function loadFractions(search: string, page: number = currentPage) {
const companyId = companyStore.activeCompany?.id;
if (!companyId || isLoadingFractions) return;
isLoadingFractions = true;
try {
const filters = search ? { search } : {};
const pageSize = 100;
const response = await getTariffFractions(page, pageSize, companyId, filters);
if (response.data) {
if (page === 1) {
tariffFractions = [...response.data.items];
} else {
tariffFractions = [...tariffFractions, ...response.data.items];
}
totalFractions = response.data.total;
currentPage = page;
hasMoreFractions = tariffFractions.length < response.data.total;
}
} catch (error) {
console.error('Error cargando fracciones arancelarias:', error);
} finally {
isLoadingFractions = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="w-full max-w-[95vw] sm:max-w-7xl">
<Dialog.Header>
<Dialog.Title>CATALOGO DE FRACCIONES SITAR - SCAII</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
<div>
<Label for="search_fraction">Buscando:</Label>
<div class="relative mt-1">
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
<Input
id="search_fraction"
bind:value={searchFraction}
placeholder="Buscar por fracción, descripción, NICO..."
class="pl-8"
oninput={() => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
tariffFractions = [];
currentPage = 1;
totalFractions = 0;
hasMoreFractions = true;
loadFractions(searchFraction, 1);
}, 500);
}}
/>
</div>
</div>
<div
class="max-h-[600px] overflow-x-auto overflow-y-auto rounded-md border"
onscroll={(e) => {
const target = e.currentTarget;
if (
target.scrollHeight - target.scrollTop <= target.clientHeight + 50 &&
hasMoreFractions &&
!isLoadingFractions
) {
loadFractions(searchFraction, currentPage + 1);
}
}}
>
<table class="w-full">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Clave</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Fracción</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">NICO</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Descripción</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">U.M.T</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Adv. Impo</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Adv. Expo</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">DOF</th>
<th class="px-4 py-2 text-left font-semibold whitespace-nowrap">Aplica IEPS</th>
</tr>
</thead>
<tbody>
{#each tariffFractions as fraction (fraction.id)}
<tr
class="cursor-pointer border-b transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={() => {
onSelect(fraction);
open = false;
}}
>
<td class="px-4 py-2 font-mono whitespace-nowrap">{fraction.um_code}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.fraction}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.nico}</td>
<td class="px-4 py-2">{fraction.description}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.umt}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.adv_impo}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.adv_expo}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.dof}</td>
<td class="px-4 py-2 whitespace-nowrap">{fraction.aplica_ieps}</td>
</tr>
{:else}
<tr>
<td colspan="9" class="px-4 py-8 text-center text-muted-foreground">
{#if isLoadingFractions}
<div class="flex items-center justify-center gap-2">
<Loader2 class="h-4 w-4 animate-spin" />
Cargando fracciones...
</div>
{:else}
No hay fracciones disponibles
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if isLoadingFractions && tariffFractions.length > 0}
<div class="flex justify-center py-2">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div>
{/if}
</div>
<div class="flex justify-end">
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,156 +1,14 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import * as Table from "$lib/components/ui/table";
import { toast } from "svelte-sonner";
import { Search, Loader2, Hash } from "lucide-svelte";
import { getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions";
import { companyStore } from "$lib/stores/company.svelte";
import TariffFractionSelector from '$lib/components/dashboard/goods/modales/TariffFractionSelector.svelte';
import { type TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean,
onSelect: (item: TariffFraction) => void
} = $props();
// --- ESTADO ---
let items = $state<TariffFraction[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
// Filtro local
let filteredItems = $derived(
items.filter(i =>
(i.fraction || "").includes(searchTerm) ||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(i.nico || "").includes(searchTerm) ||
(i.code || "").toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Cargar datos al abrir
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadFractions();
}
});
async function loadFractions() {
if (!companyStore.activeCompany?.id) {
toast.error("No hay empresa seleccionada");
return;
}
loading = true;
try {
const response = await getTariffFractions(1, 1000, companyStore.activeCompany.id);
if (response.error) {
console.error("Error al cargar fracciones:", response.error);
toast.error(`Error: ${response.error}`);
return;
}
if (response.data?.items) {
items = response.data.items;
loaded = true;
} else {
console.warn("No se encontraron fracciones:", response);
toast.info("No se encontraron fracciones registradas");
}
} catch (e: any) {
console.error("Excepción cargando fracciones:", e);
toast.error(`Error de conexión: ${e.message || e}`);
} finally {
loading = false;
}
}
function handleSelect(item: TariffFraction) {
if (onSelect) onSelect(item);
open = false;
}
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: TariffFraction) => void;
} = $props();
</script>
<Dialog.Root bind:open={open}>
<Dialog.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Fracción Arancelaria</Dialog.Title>
<Dialog.Description>
Seleccione la fracción arancelaria del catálogo.
</Dialog.Description>
</Dialog.Header>
<div class="relative w-full my-2">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por fracción, NICO o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading}
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredItems.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron fracciones.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Código</Table.Head>
<Table.Head class="w-[120px]">Fracción</Table.Head>
<Table.Head class="w-[80px]">NICO</Table.Head>
<Table.Head>Descripción</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredItems as item}
<Table.Row
class="cursor-pointer hover:bg-accent/50 transition-colors"
onclick={() => handleSelect(item)}
>
<Table.Cell class="font-mono text-xs text-muted-foreground">
{item.code}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Hash class="h-3 w-3 text-orange-500" />
<span class="font-mono font-bold text-orange-600 dark:text-orange-400">
{item.fraction}
</span>
</div>
</Table.Cell>
<Table.Cell class="font-mono text-sm">
{item.nico || '-'}
</Table.Cell>
<Table.Cell class="font-medium text-sm">
{item.description || '-'}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
{filteredItems.length} registros encontrados
</div>
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<TariffFractionSelector bind:open {onSelect} />

View File

@@ -1,204 +1,14 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Loader2, Search } from 'lucide-svelte';
import TariffFractionSelector from '$lib/components/dashboard/goods/modales/TariffFractionSelector.svelte';
import { type TariffFraction } from '$lib/api/dashboard/a76/general_catalogs/tariff-fractions';
let {
open = $bindable(),
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (fraction: any) => void;
onSelect: (item: TariffFraction) => void;
} = $props();
let fractions: any[] = $state([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let error = $state('');
// Pagination state
let currentPage = $state(1);
let totalPages = $state(1);
let hasMore = $state(true);
const pageSize = 100;
// Scroll container reference
let scrollContainer: HTMLDivElement | null = $state(null);
async function loadFractions(page: number = 1, append: boolean = false) {
if (page === 1) {
loading = true;
} else {
loadingMore = true;
}
error = '';
try {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
...(searchTerm.trim() && { search: searchTerm.trim() })
});
const response = await fetch(`/api-sveltekit/tariff-fractions?${params}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.items && Array.isArray(data.items)) {
if (append) {
fractions = [...fractions, ...data.items];
} else {
fractions = data.items;
}
currentPage = data.page;
totalPages = data.pages;
hasMore = currentPage < totalPages;
} else {
console.error('Unexpected data format:', data);
if (!append) fractions = [];
}
} else {
error = `Error: ${response.status} - ${response.statusText}`;
console.error('Error response:', await response.text());
}
} catch (err) {
error = 'Error loading tariff fractions';
console.error('Error loading tariff fractions:', err);
} finally {
loading = false;
loadingMore = false;
}
}
function handleScroll(e: Event) {
if (!scrollContainer || loading || loadingMore || !hasMore) return;
const target = e.target as HTMLDivElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Load more when within 200px of bottom
if (scrollBottom < 200) {
loadFractions(currentPage + 1, true);
}
}
function handleSelect(fraction: any) {
onSelect(fraction);
open = false;
}
// Effect to load initial data when dialog opens
$effect(() => {
if (open) {
fractions = [];
currentPage = 1;
hasMore = true;
loadFractions(1, false);
}
});
// Effect to reload when search changes
let searchDebounce: ReturnType<typeof setTimeout>;
$effect(() => {
if (open) {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
fractions = [];
currentPage = 1;
hasMore = true;
loadFractions(1, false);
}, 300);
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl w-full max-h-[80vh] flex flex-col px-6">
<Dialog.Header class="px-6 py-4 border-b">
<Dialog.Title class="text-lg font-semibold">FRACCIONES ARANCELARIAS</Dialog.Title>
</Dialog.Header>
<div class="px-6 py-3 border-b bg-zinc-50 dark:bg-zinc-900">
<div class="flex items-center gap-2">
<Search class="w-4 h-4 text-zinc-400" />
<Input
bind:value={searchTerm}
placeholder="Buscar por código, fracción, descripción, NICO o UMT..."
class="flex-1 h-9"
/>
</div>
<p class="text-xs text-zinc-500 mt-2">
Mostrando {fractions.length} de {currentPage * pageSize} resultados
</p>
</div>
<div bind:this={scrollContainer} onscroll={handleScroll} class="flex-1 overflow-auto px-6 py-4">
{#if loading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-8 h-8 animate-spin text-zinc-900 dark:text-zinc-100" />
</div>
{:else if error}
<div class="flex items-center justify-center py-20 text-red-600">
<p>{error}</p>
</div>
{:else}
<div class="border rounded-md overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white sticky top-0">
<tr>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Código</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Fracción</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Descripción</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">NICO</th>
<th class="px-3 py-2 text-left font-semibold">UMT</th>
</tr>
</thead>
<tbody>
{#each fractions as fraction, i}
<tr
class="border-b hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
onclick={() => handleSelect(fraction)}
>
<td class="px-3 py-2 border-r">{fraction.code || ''}</td>
<td class="px-3 py-2 border-r">{fraction.fraction || ''}</td>
<td class="px-3 py-2 border-r">{fraction.description || ''}</td>
<td class="px-3 py-2 border-r text-center">{fraction.nico || ''}</td>
<td class="px-3 py-2 text-center">{fraction.umt || ''}</td>
</tr>
{/each}
{#if fractions.length === 0 && !loading}
<tr>
<td colspan="5" class="px-3 py-8 text-center text-zinc-500">
No se encontraron resultados
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{#if loadingMore}
<div class="flex items-center justify-center py-4">
<Loader2 class="w-6 h-6 animate-spin text-zinc-900 dark:text-zinc-100" />
<span class="ml-2 text-sm text-zinc-600 dark:text-zinc-400">Cargando más...</span>
</div>
{/if}
{#if !hasMore && fractions.length > 0}
<div class="text-center py-4 text-sm text-zinc-500">Todos los resultados cargados</div>
{/if}
{/if}
</div>
<div class="px-6 py-3 border-t bg-zinc-50 dark:bg-zinc-900 flex items-center justify-end gap-2">
<Button variant="outline" size="sm">Editar</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<TariffFractionSelector bind:open {onSelect} />

View File

@@ -76,11 +76,12 @@ class CompanyStore {
this._loading = true;
try {
const response = await fetch('/api/v1/a76/company/my-companies', {
credentials: 'include'
});
if (response.ok) {
const newCompanies = await response.json();
// Importamos dinámicamente para evitar dependencias circulares si las hubiera
const { api } = await import('$lib/api');
const response = await api.get<Company[]>('/v1/a76/company/my-companies');
if (response.data) {
const newCompanies = response.data;
// Detectar si el tenant ha cambiado
if (newCompanies.length > 0) {
@@ -88,7 +89,6 @@ class CompanyStore {
// Si el tenant cambió, limpiar el store primero
if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) {
this.clear();
}
@@ -102,7 +102,7 @@ class CompanyStore {
this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
}
} else {
console.error('Error loading companies:', response.statusText);
console.error('Error loading companies:', response.error);
// Si falla la carga (ej: 401), limpiar el store
if (response.status === 401) {
this.clear();

View File

@@ -13,14 +13,15 @@
import { obtenerAtajosListaFracciones } from '$lib/config/shortcuts/dashboard/general_catalogs/tariff_fractions/list';
import { onMount } from 'svelte';
// Estado
let tariffFractions = $state<TariffFraction[]>([]);
let filteredFractions = $state<TariffFraction[]>([]);
let searchQuery = $state('');
let isLoading = $state(false);
let currentPage = $state(1);
let totalPages = $state(1);
let totalRecords = $state(0);
const pageSize = 50;
let searchTimeout: NodeJS.Timeout;
// Atajos
useShortcuts(
@@ -35,24 +36,24 @@
loadTariffFractions();
});
// Filtrar fracciones cuando cambia la búsqueda
// Efecto para búsqueda con debounce
$effect(() => {
if (searchQuery.trim() === '') {
filteredFractions = tariffFractions;
} else {
const query = searchQuery.toLowerCase();
filteredFractions = tariffFractions.filter(
(fraction) =>
fraction.code.toLowerCase().includes(query) ||
fraction.fraction.toLowerCase().includes(query) ||
(fraction.description ?? '').toLowerCase().includes(query) ||
(fraction.nico ?? '').toLowerCase().includes(query) ||
(fraction.umt ?? '').toLowerCase().includes(query)
);
}
// Limpiar timeout anterior
clearTimeout(searchTimeout);
// Setup nuevo timeout
searchTimeout = setTimeout(() => {
// Resetear a página 1 cuando cambia la búsqueda
if (currentPage !== 1) {
currentPage = 1;
}
loadTariffFractions(1, searchQuery);
}, 500);
return () => clearTimeout(searchTimeout);
});
async function loadTariffFractions(page: number = 1) {
async function loadTariffFractions(page: number = 1, search: string = '') {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
console.error('No hay compañía activa');
@@ -61,10 +62,15 @@
isLoading = true;
try {
const response = await getTariffFractions(page, pageSize, companyId);
// Preparar filtros
const filters: Record<string, any> = {};
if (search.trim()) {
filters.search = search.trim();
}
const response = await getTariffFractions(page, pageSize, companyId, filters);
if (response.data) {
tariffFractions = response.data.items;
filteredFractions = response.data.items;
totalPages = response.data.pages;
totalRecords = response.data.total;
currentPage = response.data.page;
@@ -78,31 +84,31 @@
async function goToPage(page: number) {
if (page >= 1 && page <= totalPages && page !== currentPage) {
await loadTariffFractions(page);
await loadTariffFractions(page, searchQuery);
}
}
</script>
<div class="container mx-auto p-6">
<Card.Root>
<Card.Header class="text-center border-b">
<Card.Header class="border-b text-center">
<Card.Title class="text-2xl font-bold uppercase">
Catálogo de Fracciones SITAR - SCAII
</Card.Title>
<p class="text-muted-foreground mt-2">Nomenclatura arancelaria mexicana completa</p>
<p class="mt-2 text-muted-foreground">Nomenclatura arancelaria mexicana completa</p>
</Card.Header>
<Card.Content class="pt-6">
<div class="space-y-6">
<!-- Barra de búsqueda y acciones -->
<div class="flex gap-4 items-center">
<div class="flex-1 relative">
<div class="flex items-center gap-4">
<div class="relative flex-1">
<Search
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
bind:value={searchQuery}
placeholder="Buscar por código, fracción, descripción, NICO o UMT..."
placeholder="Buscar por código, fracción, descripción, NICO o UMT (Búsqueda en servidor)..."
class="pl-9"
/>
</div>
@@ -120,13 +126,13 @@
<span>Cargando...</span>
</div>
{:else}
Mostrando {filteredFractions.length} de {totalRecords} fracciones arancelarias
Mostrando {tariffFractions.length} de {totalRecords} fracciones arancelarias
{#if searchQuery}
(filtrado)
{/if}
{/if}
</div>
{#if !searchQuery && totalPages > 1}
{#if totalPages > 1}
<div class="flex items-center gap-2">
<span>Página {currentPage} de {totalPages}</span>
</div>
@@ -134,9 +140,9 @@
</div>
<!-- Tabla de fracciones -->
<div class="border rounded-md overflow-auto max-h-[600px]">
<div class="max-h-[600px] overflow-auto rounded-md border">
<Table.Root>
<Table.Header class="bg-background z-10">
<Table.Header class="z-10 bg-background">
<Table.Row>
<Table.Head class="w-[100px]">Código</Table.Head>
<Table.Head class="w-[120px]">Fracción</Table.Head>
@@ -148,9 +154,9 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#if filteredFractions.length === 0}
{#if tariffFractions.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="text-center py-8 text-muted-foreground">
<Table.Cell colspan={7} class="py-8 text-center text-muted-foreground">
{#if isLoading}
Cargando fracciones arancelarias...
{:else if searchQuery}
@@ -161,7 +167,7 @@
</Table.Cell>
</Table.Row>
{:else}
{#each filteredFractions as fraction (fraction.id)}
{#each tariffFractions as fraction (fraction.id)}
<Table.Row class="hover:bg-muted/50">
<Table.Cell class="font-mono text-sm">{fraction.code}</Table.Cell>
<Table.Cell class="font-mono text-sm font-medium"
@@ -182,7 +188,7 @@
</div>
<!-- Paginación -->
{#if !searchQuery && totalPages > 1}
{#if totalPages > 1}
<div class="flex items-center justify-center gap-2">
<Button
variant="outline"