diff --git a/backend/api/v1/modules/a24/fa/fa_parts/models.py b/backend/api/v1/modules/a24/fa/fa_parts/models.py index ba5bbe75..ad813d8e 100644 --- a/backend/api/v1/modules/a24/fa/fa_parts/models.py +++ b/backend/api/v1/modules/a24/fa/fa_parts/models.py @@ -29,7 +29,7 @@ class FaPart(Base, TenantScopedMixin, TimestampMixin): ForeignKeyConstraint( ["id"], ["a76.parts.id"], name="fk_fa_partes_master" ), - {"schema": "a24"}, + {"schema": "a24", "extend_existing": True}, ) # El ID hereda el valor de la tabla parts diff --git a/backend/api/v1/modules/a24/inv/inv_parts/models.py b/backend/api/v1/modules/a24/inv/inv_parts/models.py index 467bbc2c..5e8c76e2 100644 --- a/backend/api/v1/modules/a24/inv/inv_parts/models.py +++ b/backend/api/v1/modules/a24/inv/inv_parts/models.py @@ -32,7 +32,7 @@ class InvPart(Base, TenantScopedMixin, TimestampMixin): ForeignKeyConstraint( ["id"], ["a76.parts.id"], name="fk_inv_partes_master" ), - {"schema": "a24"}, + {"schema": "a24", "extend_existing": True}, ) # Relación 1:1 - El ID es el mismo de la tabla maestra diff --git a/backend/api/v1/modules/a76/classes/models.py b/backend/api/v1/modules/a76/classes/models.py index e97a2f80..a5288980 100644 --- a/backend/api/v1/modules/a76/classes/models.py +++ b/backend/api/v1/modules/a76/classes/models.py @@ -48,7 +48,7 @@ class Class(Base, TenantScopedMixin, TimestampMixin): "class_code", name="uq_classes_tenant_company_code", ), - {"schema": "a76"}, + {"schema": "a76", "extend_existing": True}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index b291dbb5..a7e5e1ef 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -26,7 +26,7 @@ class Company(Base, TimestampMixin): __tablename__ = "company" #GEmpresa __table_args__ = ( PrimaryKeyConstraint("id", name="company_pkey"), - {"schema": "a76"}, + {"schema": "a76", "extend_existing": True}, ) # Primary key diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 294ff490..7bbad040 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -3,8 +3,12 @@ Rutas para gestión de empresa """ from typing import List, Optional +import os +import shutil +from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from core.database import get_core_db @@ -298,11 +302,98 @@ async def update_company( return CompanyResponseDTO.model_validate(updated_company) -@router.delete( - "/{company_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete company", + return CompanyResponseDTO.model_validate(updated_company) + + +@router.post( + "/{company_id}/upload-logo", + response_model=dict, + summary="Upload company logo", ) +async def upload_company_logo( + company_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Upload logo for a company""" + tenant_id = current_user.get("tenant_id") + if not tenant_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + + # 1. Verify company exists + company = CompanyService.get_by_id(db, company_id, tenant_id, 0) + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Company not found", + ) + + # 2. Define upload path + # Use a persistent path: 'app_data/logos/{company_id}' + upload_dir = Path(f"app_data/logos/{company_id}") + upload_dir.mkdir(parents=True, exist_ok=True) + + # 3. Save file + # Preserve original filename + filename = file.filename or "logo.png" + file_path = upload_dir / filename + + try: + # Check if file exists and remove it to avoid accumulation if needed, + # or just overwrite (shutil.copyfileobj overwrites) + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Could not save file: {e}", + ) + + # 4. Returns the absolute path keys + abs_path = str(file_path.absolute()) + + return {"path": abs_path} + + +@router.get( + "/{company_id}/logo/image", + summary="Get company logo image", +) +@router.get( + "/{company_id}/logo/image", + summary="Get company logo image", +) +async def get_company_logo_image( + company_id: int, + db: Session = Depends(get_core_db), + # Public endpoint to allow tags to load the image without custom headers +): + """Serve the company logo image file""" + # Security: In a stricter environment, we would use a signed short-lived URL + # or cookie-based auth. For now, checking if company exists is sufficient. + + # We find the company ignoring tenant checks for the image serving + # (Logos are generally considered semi-public assets in this context) + company = db.query(Company).filter(Company.id == company_id).first() + + if not company or not company.logo: + raise HTTPException(status_code=404, detail="Logo not found") + + file_path = Path(company.logo) + if not file_path.exists(): + # Fallback for old paths or moved files + # Check if it exists in the 'standard' location even if DB thinks otherwise + standard_path = Path(f"app_data/logos/{company_id}") / file_path.name + if standard_path.exists(): + return FileResponse(standard_path) + + raise HTTPException(status_code=404, detail="Logo file not found on server") + + return FileResponse(file_path) async def delete_company( company_id: int, db: Session = Depends(get_core_db), diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py index 19304472..f032f76e 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/service.py @@ -34,10 +34,13 @@ class BaseService: limit: int = 100, filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Any], int]: - query = db.query(cls.model).filter( - cls.model.tenant_id == tenant_id, - cls.model.company_id == company_id, - ) + query = db.query(cls.model) + + if hasattr(cls.model, "tenant_id"): + query = query.filter(cls.model.tenant_id == tenant_id) + + if hasattr(cls.model, "company_id"): + query = query.filter(cls.model.company_id == company_id) if filters: if filters.get("code"): @@ -56,11 +59,15 @@ class BaseService: def get_by_id( cls, db: Session, id: int, tenant_id: int, company_id: int ) -> Optional[Any]: - return db.query(cls.model).filter( - cls.model.id == id, - cls.model.tenant_id == tenant_id, - cls.model.company_id == company_id, - ).first() + query = db.query(cls.model).filter(cls.model.id == id) + + if hasattr(cls.model, "tenant_id"): + query = query.filter(cls.model.tenant_id == tenant_id) + + if hasattr(cls.model, "company_id"): + query = query.filter(cls.model.company_id == company_id) + + return query.first() @classmethod def create( @@ -70,9 +77,15 @@ class BaseService: tenant_id: int, company_id: int, ) -> Any: - db_obj = cls.model( - **data.model_dump(), tenant_id=tenant_id, company_id=company_id - ) + create_kwargs = data.model_dump() + + if hasattr(cls.model, "tenant_id"): + create_kwargs["tenant_id"] = tenant_id + + if hasattr(cls.model, "company_id"): + create_kwargs["company_id"] = company_id + + db_obj = cls.model(**create_kwargs) db.add(db_obj) db.commit() db.refresh(db_obj) diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index db56d294..42e1d6ed 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -22,14 +22,16 @@ from sqlalchemy import ( # Importante usar relationship y Mapped from sqlalchemy.orm import Mapped, mapped_column, relationship +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.a24.fa.fa_parts.models import FaPart +from api.v1.modules.a24.inv.inv_parts.models import InvPart + + if TYPE_CHECKING: from api.v1.modules.a76.classes.models import Class - from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.a76.general_catalogs.units_of_measure.models import ( UnitOfMeasure, ) - from api.v1.modules.a24.fa.fa_parts.models import FaPart - from api.v1.modules.a24.inv.inv_parts.models import InvPart class Part(Base, TenantScopedMixin, TimestampMixin): diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index e07807f8..69d502f4 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -1,8 +1,9 @@ import shutil +import base64 import pdfkit from pathlib import Path from decimal import Decimal -from typing import Tuple, List +from typing import Tuple, List, Callable, Optional from jinja2 import Environment, FileSystemLoader, select_autoescape from fastapi import HTTPException @@ -81,16 +82,19 @@ class FacturaImportacionMexService: autorizacion=prog.program_number if prog else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int) -> FacturaImportacionCompleta: + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: try: + if progress_callback: progress_callback(10, "Buscando factura...") header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") compliance = header.compliance_mx - logistics = header.logistics[0] if header.logistics else None + logistics = header.logistics if header.logistics else None + if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None + if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") proveedor_id = compliance.provider_id if compliance else None cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / Supplier") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") @@ -112,28 +116,35 @@ class FacturaImportacionMexService: remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + patente_val = "" + if pedimento and pedimento.license: + patente_val = pedimento.license + elif 'broker' in locals() and broker and broker.license: + patente_val = broker.license + factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", fecha=str(header.invoice_date) if header.invoice_date else "", tipo_cambio=float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0, moneda=getattr(header, 'currency', "USD") or "USD", - incoterm=logistics.incoterm if logistics else "", + incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", pedimento=pedimento.pedimento_number if pedimento else "", clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=pedimento.regime if pedimento else "", - patente=pedimento.license if pedimento else "", + patente=patente_val, agente_aduanal=nombre_agente, - transporte=str(logistics.transport_type.value) if (logistics and logistics.transport_type) else "", - num_transporte=logistics.trailer_num if logistics else "", - placas=logistics.license_plate if logistics else "", - transportista=logistics.carrier_id if logistics else "", + transporte=str(logistics.transport_type) if (logistics and logistics.transport_type) else "", + num_transporte=(logistics.trailer_num or "") if logistics else "", + placas=(logistics.license_plate or "") if logistics else "", + transportista=(logistics.carrier_id or "") if logistics else "", aduana=pedimento.customs_office if pedimento else "", - precinto=logistics.seal_number if logistics else "", - destino=logistics.destination_goods if logistics else "", + precinto=(logistics.seal_number or "") if logistics else "", + destino=(logistics.destination_goods or "") if logistics else "", remesa=remesa_valor, acuse_electronico=acuse_valor ) - + + if progress_callback: progress_callback(50, "Procesando partidas...") lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() partidas_list = [] @@ -223,16 +234,53 @@ class FacturaImportacionMexService: valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf") -> Tuple[bytes, str, str]: - datos = self.obtener_datos(db, invoice_id, company_id) + def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + # LOGO LOGIC + logo_b64 = None + try: + # Fetch company to get logo path + # We use the passed company_id which corresponds to the active company + comp_logo = db.query(Company).filter(Company.id == company_id).first() + if comp_logo and comp_logo.logo: + p = Path(comp_logo.logo) + + # Logic robusta de búsqueda (igual que en routes.py) + target_path = p + if not target_path.exists(): + # Intentar en la ruta estándar: app_data/logos/{id}/{nombre} + # Esto cubre el caso donde solo se guardó el nombre del archivo o la ruta absoluta cambió + fallback = Path(f"app_data/logos/{company_id}") / p.name + if fallback.exists(): + target_path = fallback + + if target_path.exists(): + with open(target_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + # Detect MIME type loosely + mime = "image/png" + if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + logo_b64 = f"data:{mime};base64,{encoded_string}" + except Exception as e: + print(f"Error loading logo: {e}") + context = { 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), - 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump() + 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), + 'logo_b64': logo_b64 } html_content = self.template.render(**context) nombre = f"Factura_{datos.factura.numero}.{formato}" if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" + + if progress_callback: progress_callback(90, "Generando PDF final...") options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") return pdf, nombre, "application/pdf" \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 4feb8f8f..f7c0d72d 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -23,13 +23,16 @@ async def get_task_status( response = { "task_id": task_id, "state": task_result.state, - "result": None + "result": None, + "info": None } if task_result.state == 'FAILURE': response["result"] = str(task_result.result) elif task_result.state == 'SUCCESS': response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info return response @@ -42,4 +45,4 @@ async def trigger_descarga_factura( ): validate_access_to_resource(db, company_id, current_user) task = generar_pdf_factura_async.delay(invoice_id, company_id) - return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file + return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py index a4059f34..7b7e9783 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/task.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/task.py @@ -22,13 +22,17 @@ def generar_pdf_factura_async(self, invoice_id: int, company_id: int): service = FacturaImportacionMexService() # Update state to PROCESSING - self.update_state(state='PROCESSING', meta={'current': 1, 'total': 1, 'status': 'Generating PDF...'}) + self.update_state(state='PROCESSING', meta={'current': 5, 'total': 100, 'status': 'Iniciando generación...'}) + def progress_callback(progress: int, status: str): + self.update_state(state='PROCESSING', meta={'current': progress, 'total': 100, 'status': status}) + # 3. Generamos los bytes del PDF pdf_bytes, nombre, media_type = service.generar_factura_completa( db=db, invoice_id=invoice_id, - company_id=company_id + company_id=company_id, + progress_callback=progress_callback ) # 4. Codificamos a base64 para que viaje seguro por Valkey diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/tem/schemas.py b/backend/api/v1/modules/a76/reports/importacion/facturas/tem/schemas.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/tem/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/tem/service.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html index 7d5cf1d9..d73740b6 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_mex_ver.html @@ -1,79 +1,308 @@ + Factura Importacion Mexicana - {{ factura.numero }}
-
-

Factura de Importacion

-
-

+
+

Factura de Importacion

+
+

-

-


+

+


-
-
+
+
+ {% if logo_b64 %} +
+ +
+ {% endif %}

{{ cliente_proveedor.header }}

{{ cliente_proveedor.nombre }}

-

{{ cliente_proveedor.direccion }} - {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} - {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %} +

{{ cliente_proveedor.direccion }} + {% if cliente_proveedor.num_exterior %} Ext: {{ cliente_proveedor.num_exterior }}{% endif %} + {% if cliente_proveedor.num_interior %} Int: {{ cliente_proveedor.num_interior }}{% endif %}

-

{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ cliente_proveedor.codigo_postal }}{% endif %}

+

{{ cliente_proveedor.colonia }} {% if cliente_proveedor.codigo_postal %} CP: {{ + cliente_proveedor.codigo_postal }}{% endif %}

{{ cliente_proveedor.ciudad }}, {{ cliente_proveedor.estado }}, {{ cliente_proveedor.pais }}

-

TAX ID: {{ cliente_proveedor.tax_id }} - {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} - {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} - {% endif %} +

TAX ID: {{ cliente_proveedor.tax_id }} + {% if cliente_proveedor.programa and cliente_proveedor.programa != 'Ninguno' %} + {{ cliente_proveedor.programa }}: {{ cliente_proveedor.autorizacion }} + {% endif %}


@@ -82,151 +311,178 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

FACTURA:

-
-

{{ factura.numero }}

-
-

Fecha:

-
-

{{ factura.fecha }}

-
-

T. Cambio:

-
-

{{ factura.tipo_cambio }}

-
-

Pedimento:

-
-

{{ factura.pedimento }}

-
-

Clave:

-
-

{{ factura.clave_pedimento }}

-
-

Remesa:

-
-

{{ factura.remesa }}

-
-

Acuse:

-
-

{{ factura.acuse_electronico or 'N/A' }}

-
-

Agente Aduanal:

-

{{ factura.agente_aduanal or '' }}

-
-

Patente: {{ factura.patente or '' }}

-
- Regimen:{{ factura.regimen or '' }} - -

INCOTERM:

-

{{ factura.incoterm or '' }}

-
- {% if factura.precinto %} -

Precinto: {{ factura.precinto }}

- {% endif %} -
-

Aduana: {{ factura.aduana }}

-
- {% if factura.destino %} -

Destino: {{ factura.destino }}

- {% endif %} -
-
+ + +

FACTURA:

+ + +

{{ factura.numero }}

+ + + + +

Fecha:

+ + +

{{ factura.fecha }}

+ + +

T. Cambio:

+ + +

{{ factura.tipo_cambio }}

+ + + + +

Pedimento:

+ + +

{{ factura.pedimento }}

+ + +

Clave:

+ + +

{{ factura.clave_pedimento }}

+ + + + +

Remesa:

+ + +

{{ factura.remesa }}

+ + +

Acuse:

+ + +

{{ factura.acuse_electronico or 'N/A' }}

+ + + + +

Agente Aduanal:

+

{{ factura.agente_aduanal or '' }}

+ + + + +

Patente: {{ factura.patente or '' }}

+ + + Regimen:{{ + factura.regimen or '' }} + + +

INCOTERM:

+

{{ factura.incoterm or '' }}

+ + + + + {% if factura.precinto %} +

Precinto: {{ factura.precinto }}

+ {% endif %} + + +

Aduana: {{ factura.aduana }}

+ + + {% if factura.destino %} +

Destino: {{ factura.destino }}

+ {% endif %} + + + + +

{{ cliente_vendido.header }}

{{ cliente_vendido.nombre }}

-

{{ cliente_vendido.direccion }} - {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} - {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %} +

{{ cliente_vendido.direccion }} + {% if cliente_vendido.num_exterior %} Ext: {{ cliente_vendido.num_exterior }}{% endif %} + {% if cliente_vendido.num_interior %} Int: {{ cliente_vendido.num_interior }}{% endif %}

-

{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ cliente_vendido.codigo_postal }}{% endif %}

-

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }}

-

RFC: {{ cliente_vendido.tax_id }} - {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} - {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} - {% endif %} +

{{ cliente_vendido.colonia }} {% if cliente_vendido.codigo_postal %} CP: {{ + cliente_vendido.codigo_postal }}{% endif %}

+

{{ cliente_vendido.ciudad }}, {{ cliente_vendido.estado }}, {{ cliente_vendido.pais }} +

+

RFC: {{ cliente_vendido.tax_id }} + {% if cliente_vendido.programa and cliente_vendido.programa != 'Ninguno' %} + {{ cliente_vendido.programa }}: {{ cliente_vendido.autorizacion }} + {% endif %}

{{ cliente_enviado.header }}

{{ cliente_enviado.nombre }}

-

{{ cliente_enviado.direccion }} - {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} - {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +

{{ cliente_enviado.direccion }} + {% if cliente_enviado.num_exterior %} Ext: {{ cliente_enviado.num_exterior }}{% endif %} + {% if cliente_enviado.num_interior %} Int: {{ cliente_enviado.num_interior }}{% endif %} +

+

{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ + cliente_enviado.codigo_postal }}{% endif %}

+

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}

-

{{ cliente_enviado.colonia }} {% if cliente_enviado.codigo_postal %} CP: {{ cliente_enviado.codigo_postal }}{% endif %}

-

{{ cliente_enviado.ciudad }}, {{ cliente_enviado.estado }}, {{ cliente_enviado.pais }}

RFC: {{ cliente_enviado.tax_id }}


- - +
+ - - - - - + + + + + - + - - + + + - - + + @@ -273,51 +529,51 @@

Total

- - + + {% for partida in partidas %} - - - - - - - - - - - - {% endfor %} + + + + + + + + + + + + {% endfor %} - + @@ -355,17 +611,20 @@

{{ cliente_proveedor.nombre }}


-

Los valores expresados en esta factura son en: {{ factura.moneda }}

+

Los valores expresados en esta factura + son en: {{ factura.moneda }}

- + - - -

Transportista:

{{ factura.transportista }}

SCAC: {{ factura.scac }}

INCOTERM:

{{ factura.incoterm }}

+

Transportista:

+
+

{{ factura.transportista }}

+
+

SCAC: {{ factura.scac }}

+
+

INCOTERM:

+
+

{{ factura.incoterm }}

+

Aduana: {{ factura.aduana }}


+


+

Transporte:

-

{{ factura.transporte }}: {{ factura.num_transporte }}

CAAT: {{ factura.caat }}

+

{{ factura.transporte }}: {{ factura.num_transporte }}

+
+

CAAT: {{ factura.caat }}

+

Placas: {{ factura.placas or 'N/A' }}



+


+
+


+
-

{{ loop.index }}

-
-

{{ partida.numero_parte }}

-

{{ partida.descripcion }}

-

Frac: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}

-

- {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} - {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} -

-
-

{{ partida.cantidad_importacion }}

-
-

{{ partida.unidad_medida }}

-
-

- {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} - {{ partida.clave_bultos }} -

-
-

{{ partida.peso_neto }}

-
-

{{ partida.peso_bruto }}

-
-

${{ partida.valor_costo_unitario }}

-
-

${{ partida.valor_total }}

-
+

{{ loop.index }}

+
+

{{ partida.numero_parte }}

+

{{ partida.descripcion }}

+

Frac: {{ partida.fraccion }} / Orig: {{ partida.origen or 'MEX' }}

+

+ {% if partida.advalorem %}ADV: {{ partida.advalorem }}{% endif %} + {% if partida.preferencia %} / PREF: {{ partida.preferencia }}{% endif %} +

+
+

{{ partida.cantidad_importacion }}

+
+

{{ partida.unidad_medida }}

+
+

+ {% if partida.cantidad_bultos != 0 %}{{ partida.cantidad_bultos }}{% endif %} + {{ partida.clave_bultos }} +

+
+

{{ partida.peso_neto }}

+
+

{{ partida.peso_bruto }}

+
+

${{ partida.valor_costo_unitario }}

+
+

${{ partida.valor_total }}

+
@@ -332,7 +588,7 @@

- {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} + {% if totales.bultos_total != 0 %}{{ totales.bultos_total }}{% endif %} {{ totales.clave_bultos or '' }}

-


+


Normal Por Parte

-

Declaro bajo protesta de decir verdad que la información contenida en este documento es verdadera y me hago responsable de comprobar lo aquí declarado.

+

Declaro bajo protesta de decir verdad que la información contenida en + este documento es verdadera y me hago responsable de comprobar lo aquí declarado.

+ + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_tem_hor.html b/backend/api/v1/modules/a76/reports/importacion/facturas/templates/factura_tem_hor.html deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/api/v1/modules/public/reference_data/containers/models.py b/backend/api/v1/modules/public/reference_data/containers/models.py index b8d3c630..c6081c34 100644 --- a/backend/api/v1/modules/public/reference_data/containers/models.py +++ b/backend/api/v1/modules/public/reference_data/containers/models.py @@ -7,11 +7,11 @@ class Container(Base): __tablename__ = "containers" # GContenedores __table_args__ = ( PrimaryKeyConstraint("key", name="containers_pkey"), - {"schema": "public", "extend_existing": True}, # opcional + {"extend_existing": True}, # opcional ) key: Mapped[str] = mapped_column( - String(3), nullable=False + String(3), primary_key=True, nullable=False ) # mantiene ceros iniciales description: Mapped[str] = mapped_column( String(500), nullable=False diff --git a/backend/api/v1/modules/public/reference_data/material_types/models.py b/backend/api/v1/modules/public/reference_data/material_types/models.py index 04fa9478..5862649a 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/models.py +++ b/backend/api/v1/modules/public/reference_data/material_types/models.py @@ -11,7 +11,7 @@ class MaterialType(Base): ) key: Mapped[str] = mapped_column( - String(10), nullable=False) # clave del material + String(10), primary_key=True, nullable=False) # clave del material type: Mapped[str] = mapped_column(String(15), nullable=False) # tipo description: Mapped[str] = mapped_column( String(256), nullable=False diff --git a/backend/app_data/logos/1/AS.png b/backend/app_data/logos/1/AS.png new file mode 100644 index 00000000..0ac0a679 Binary files /dev/null and b/backend/app_data/logos/1/AS.png differ diff --git a/backend/app_data/logos/1/Agenda.png b/backend/app_data/logos/1/Agenda.png new file mode 100644 index 00000000..e7b87dfd Binary files /dev/null and b/backend/app_data/logos/1/Agenda.png differ diff --git a/backend/app_data/logos/1/a519dfd146c1bd477bcd841afe02d7de.jpg b/backend/app_data/logos/1/a519dfd146c1bd477bcd841afe02d7de.jpg new file mode 100644 index 00000000..5723bd91 Binary files /dev/null and b/backend/app_data/logos/1/a519dfd146c1bd477bcd841afe02d7de.jpg differ diff --git a/backend/app_data/logos/1/footer.png b/backend/app_data/logos/1/footer.png new file mode 100644 index 00000000..bd1967bd Binary files /dev/null and b/backend/app_data/logos/1/footer.png differ diff --git a/backend/app_data/logos/1/logo2.jpg b/backend/app_data/logos/1/logo2.jpg new file mode 100644 index 00000000..e676a700 Binary files /dev/null and b/backend/app_data/logos/1/logo2.jpg differ diff --git a/backend/app_data/logos/company_1_logo.png b/backend/app_data/logos/company_1_logo.png new file mode 100644 index 00000000..0ac0a679 Binary files /dev/null and b/backend/app_data/logos/company_1_logo.png differ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5d863da1..6f625e19 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -45,7 +45,7 @@ async function refreshToken(): Promise { if (!browser) return null; let refreshTokenValue = localStorage.getItem('refresh_token'); - + // Si no está en localStorage, intentar obtenerlo de las cookies if (!refreshTokenValue) { const getCookie = (name: string): string | null => { @@ -54,18 +54,18 @@ async function refreshToken(): Promise { if (parts.length === 2) return parts.pop()?.split(';').shift() || null; return null; }; - + refreshTokenValue = getCookie('refresh_token'); - if (refreshTokenValue) { + if (refreshTokenValue) { localStorage.setItem('refresh_token', refreshTokenValue); } } - + if (!refreshTokenValue) { console.error('❌ [API] No hay refresh token disponible'); return null; - } - + } + try { const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, { method: 'POST', @@ -93,25 +93,25 @@ async function refreshToken(): Promise { return null; } - const data = await response.json(); + const data = await response.json(); // Guardar los nuevos tokens if (data.access_token) { localStorage.setItem('access_token', data.access_token); - + if (data.refresh_token) { localStorage.setItem('refresh_token', data.refresh_token); } - + // Actualizar también las cookies const isSecure = window.location.protocol === 'https:'; const secureFlag = isSecure ? '; Secure' : ''; - + document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`; if (data.refresh_token) { document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`; } - + // Actualizar el authStore si está disponible try { const { authStore } = await import('./auth'); @@ -120,7 +120,7 @@ async function refreshToken(): Promise { // Si no se puede importar authStore, no es crítico console.warn('⚠️ [API] No se pudo actualizar authStore:', e); } - + return data.access_token; } @@ -140,7 +140,7 @@ async function fetchApi( retryCount = 0 ): Promise> { // Si ya estamos refrescando el token, esperar - if (isRefreshing && retryCount === 0) { + if (isRefreshing && retryCount === 0) { return new Promise((resolve) => { subscribeTokenRefresh((newToken) => { resolve(fetchApi(endpoint, options, 1)); @@ -149,16 +149,20 @@ async function fetchApi( } const token = getToken(); - + if (!token && !endpoint.includes('/auth/login')) { console.warn('⚠️ [API] No hay token disponible para', endpoint); } const headers: Record = { - 'Content-Type': 'application/json', ...((options.headers as Record) || {}) }; + // Only set Content-Type to application/json if not already set and body is not FormData + if (!headers['Content-Type'] && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + if (token) { headers['Authorization'] = `Bearer ${token}`; } @@ -171,7 +175,7 @@ async function fetchApi( }); // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token - if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { isRefreshing = true; try { @@ -226,7 +230,7 @@ async function fetchApi( // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; - + // FastAPI devuelve errores de validación en data.detail como array if (Array.isArray(data.detail)) { const errors = data.detail.map((err: any) => { @@ -239,14 +243,14 @@ async function fetchApi( } else { errorMessage += JSON.stringify(data.detail); } - + return { error: errorMessage, status: response.status }; } } - + return { error: data.message || data.detail || 'Error en la petición', status: response.status @@ -281,7 +285,7 @@ export const api = { method: 'PUT', body: JSON.stringify(body) }), - + patch: (endpoint: string, body: any) => fetchApi(endpoint, { method: 'PATCH', @@ -314,5 +318,8 @@ export const api = { myLicense: () => api.get('/v1/licenses/my-license'), usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}`), validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}`) - } + }, + + // Generic request for custom needs (like file uploads) + request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) }; diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index f90c3404..76b7e33e 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -87,7 +87,7 @@ export interface CreateCustomsBrokerData { */ export const customsBrokersApi = { list: (companyId: string) => { - return api.get(`/v1/a76/customs-brokers/?company_id=${companyId}`); + return api.get(`/v1/a76/customs-brokers?company_id=${companyId}`); }, get: (brokerKey: string, companyId: string) => { @@ -95,7 +95,7 @@ export const customsBrokersApi = { }, create: (data: CreateCustomsBrokerData, companyId: string) => { - return api.post(`/v1/a76/customs-brokers/?company_id=${companyId}`, data); + return api.post(`/v1/a76/customs-brokers?company_id=${companyId}`, data); }, update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => { diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index d31e6aba..82add8d4 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -25,6 +25,13 @@ export interface Company { order_format_type?: string | null; ctpat_svi?: string | null; trusted_exporter_number?: string | null; + logo?: string | null; + previous_code?: number | null; + client_name?: string | null; + subassembly_mode?: string | null; + inter_db_name?: string | null; + prevalidator_key?: string | null; + seventh_amendment?: boolean; created_at: string | null; updated_at: string | null; } @@ -113,3 +120,13 @@ export async function updateCompany(id: number, data: CompanyUpdate): Promise> { return await api.delete(`/v1/a76/company/${id}`); } + +export async function uploadCompanyLogo(id: number, file: File): Promise> { + const formData = new FormData(); + formData.append('file', file); + // Use api.request to pass FormData directly without JSON.stringify + return await api.request(`/v1/a76/company/${id}/upload-logo`, { + method: 'POST', + body: formData + }); +} diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts index a624e361..fb415397 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/multi-currency-types.ts @@ -1,5 +1,11 @@ import { api } from '$lib/api'; -import type { PaginatedResponse } from '$lib/types'; + +export interface PaginatedResponse { + page: number; + page_size: number; + total: number; + total_pages: number; +} export interface MultiCurrencyType { id: number; @@ -29,11 +35,13 @@ export interface MultiCurrencyTypeListResponse extends PaginatedResponse { items: MultiCurrencyType[]; } +import type { ApiResponse } from '$lib/api'; + export async function getMultiCurrencyTypes( companyId: number, page?: number, pageSize?: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); if (page) params.append('page', page.toString()); if (pageSize) params.append('page_size', pageSize.toString()); @@ -44,7 +52,7 @@ export async function getMultiCurrencyTypes( export async function getMultiCurrencyType( multiCurrencyTypeId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.get(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); } @@ -52,7 +60,7 @@ export async function getMultiCurrencyType( export async function createMultiCurrencyType( data: MultiCurrencyTypeCreate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.post(`/v1/a76/multi-currency-types/?${params.toString()}`, data); } @@ -61,7 +69,7 @@ export async function updateMultiCurrencyType( multiCurrencyTypeId: number, data: MultiCurrencyTypeUpdate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.put( `/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`, @@ -72,7 +80,7 @@ export async function updateMultiCurrencyType( export async function deleteMultiCurrencyType( multiCurrencyTypeId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.delete(`/v1/a76/multi-currency-types/${multiCurrencyTypeId}?${params.toString()}`); } \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/parts.ts b/frontend/src/lib/api/dashboard/a76/parts.ts index 88538db8..492c93ea 100644 --- a/frontend/src/lib/api/dashboard/a76/parts.ts +++ b/frontend/src/lib/api/dashboard/a76/parts.ts @@ -55,24 +55,24 @@ export interface Part { tenant_id: number; company_id: number; client_id: number; - + // Identificación part_number: string; commercial_part_number: string | null; - + // Descripciones y Clase description_spanish: string | null; description_english: string | null; part_class: string | null; - unit_of_measure: string | null; - + unit_of_measure: string | null; + // Costos y Pesos unit_cost: number | null; currency_key: string | null; currency_type: string | null; unit_weight: number | null; weight_type: string | null; - + // Regulatorio fraction: string | null; us_fraction: string | null; @@ -82,14 +82,14 @@ export interface Part { eccn: string | null; export_code: string | null; exclusion_symbol: string | null; - + // Estado y Media is_active: boolean; part_photo: string | null; created_at: string; updated_at: string; - + fa_data?: FaData | null; inv_data?: InvData | null; } @@ -99,7 +99,7 @@ export interface PartCreate extends Omit {} +export interface PartUpdate extends Partial { } export interface PartListResponse { @@ -112,20 +112,20 @@ export interface PartListResponse { export const partsApi = { - list: (params: { - company_id: number; - page?: number; - page_size?: number; - q?: string + list: (params: { + company_id: number; + page?: number; + page_size?: number; + q?: string }) => { const { company_id, page = 1, page_size = 50, q = '' } = params; const skip = (page - 1) * page_size; - + const query = new URLSearchParams({ company_id: company_id.toString(), skip: skip.toString(), limit: page_size.toString(), - description: q + description: q }); return api.get(`/v1/a76/parts/?${query.toString()}`); @@ -140,11 +140,11 @@ export const partsApi = { }, update: (id: number, data: PartUpdate, company_id: number) => { - return api.put(`/v1/a76/parts/${id}?company_id=${company_id}`, data); + return api.put(`/v1/a76/parts/${id}/?company_id=${company_id}`, data); }, delete: (id: number, company_id: number) => { - return api.delete(`/v1/a76/parts/${id}?company_id=${company_id}`); + return api.delete(`/v1/a76/parts/${id}/?company_id=${company_id}`); } }; \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte index ed216be2..c6a45fce 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/client-selector-dialog.svelte @@ -24,9 +24,10 @@ // Filtro reactivo local let filteredClients = $derived( clients.filter(c => - c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (c.client_or_provider === 'client' || c.client_or_provider === 'both') && + (c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.rfc.toLowerCase().includes(searchTerm.toLowerCase()) || - c.id.toString().includes(searchTerm) + c.id.toString().includes(searchTerm)) ) ); @@ -42,10 +43,8 @@ loading = true; try { - // Petición a la API - const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 100, { - type: 'client' - }); + // Petición a la API - Traer todos para filtrar localmente + const res = await clientsProvidersApi.list(companyStore.activeCompany.id, 1, 1000); // Normalización de respuesta const responseData = (res as any).data || res; diff --git a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte index 27cea07f..27943e6b 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/country-selector-dialog.svelte @@ -5,6 +5,7 @@ import * as Table from "$lib/components/ui/table"; import { Search, Loader2, Globe } from "lucide-svelte"; import { countriesApi, type Country } from "$lib/api/dashboard/refrence_data/countries"; + import { toast } from "svelte-sonner"; // --- PROPS --- let { @@ -33,6 +34,7 @@ // Cargar datos al abrir $effect(() => { + console.log("CountrySelectorDialog: open changed", open); if (open) { loadCountries(); } @@ -40,10 +42,17 @@ async function loadCountries() { loading = true; - console.log("Cargando países..."); + console.log("CountrySelectorDialog: loading countries..."); try { - const response = await countriesApi.list(1, 300); + // FIX: Reducir tamaño de página para evitar timeouts y manejo de errores + const response = await countriesApi.list(1, 100); console.log("Respuesta países FULL:", response); + + if (response.error) { + console.error("Error API:", response.error); + toast.error(`Error al cargar países: ${response.error}`); + return; + } // Caso 1: Estructura esperada { data: { items: [...] } } if (response.data?.items && Array.isArray(response.data.items)) { @@ -69,16 +78,19 @@ loaded = true; } else { console.warn("Estructura de datos no reconocida en countriesApi.list:", response.data); + toast.error("Formato de datos de países no reconocido"); } } else { console.warn("No se encontraron países o formato incorrecto:", response); + toast.error("No se encontraron países"); } console.log(`Países cargados: ${items.length}`); - } catch (e) { - console.error("Error cargando países:", e); + } catch (e: any) { + console.error("Error cargando países (excepción):", e); + toast.error(`Excepción al cargar países: ${e.message || e}`); } finally { loading = false; } diff --git a/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte index 2325fe34..4add0b4a 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/currency-selector-dialog.svelte @@ -3,9 +3,9 @@ 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, DollarSign } from "lucide-svelte"; - import { getMultiCurrencyTypes, type MultiCurrencyType } from "$lib/api/dashboard/a76/general_catalogs/multi-currency-types"; - import { companyStore } from "$lib/stores/company.svelte"; + import { currencyTypesApi, type CurrencyType } from "$lib/api/dashboard/refrence_data/currency_types"; // --- PROPS --- let { @@ -13,11 +13,11 @@ onSelect }: { open: boolean, - onSelect: (item: MultiCurrencyType) => void + onSelect: (item: CurrencyType) => void } = $props(); // --- ESTADO --- - let items = $state([]); + let items = $state([]); let loading = $state(false); let searchTerm = $state(""); let loaded = $state(false); @@ -25,39 +25,50 @@ // Filtro local let filteredItems = $derived( items.filter(i => - (i.currency_type_code || "").toLowerCase().includes(searchTerm.toLowerCase()) || - (i.country_key || "").toLowerCase().includes(searchTerm.toLowerCase()) + (i.code || "").toLowerCase().includes(searchTerm.toLowerCase()) || + (i.currency_name || "").toLowerCase().includes(searchTerm.toLowerCase()) || + (i.country_description || "").toLowerCase().includes(searchTerm.toLowerCase()) ) ); // Cargar datos al abrir $effect(() => { - if (open && !loaded && companyStore.activeCompany?.id) { + if (open && !loaded) { loadCurrencies(); } }); async function loadCurrencies() { - if (!companyStore.activeCompany?.id) return; - loading = true; + console.log("CurrencySelectorDialog: loading currencies (public)..."); try { - const response = await getMultiCurrencyTypes(companyStore.activeCompany.id, 1, 100); + // FIX: Usar API pública, sin company_id + const response = await currencyTypesApi.list(1, 100); + console.log("Respuesta Monedas Public FULL:", response); - if (response?.items) { - items = response.items; - loaded = true; - } else { - console.warn("No se encontraron monedas:", response); + if (response.error) { + console.error("CurrencySelectorDialog Error:", response.error); + toast.error(`Error al cargar monedas: ${response.error}`); + return; } - } catch (e) { + + if (response.data?.items) { + items = response.data.items; + loaded = true; + console.log("CurrencySelectorDialog: loaded items", items.length); + } else { + console.warn("No se encontraron monedas (public):", response); + toast.error("No se encontraron monedas"); + } + } catch (e: any) { console.error("Error cargando monedas:", e); + toast.error(`Excepción al cargar monedas: ${e.message || e}`); } finally { loading = false; } } - function handleSelect(item: MultiCurrencyType) { + function handleSelect(item: CurrencyType) { if (onSelect) onSelect(item); open = false; } @@ -68,7 +79,7 @@ Seleccionar Moneda - Seleccione el tipo de moneda del catálogo. + Seleccione el tipo de moneda del catálogo público. @@ -76,7 +87,7 @@ @@ -96,9 +107,9 @@ - Código - País - Factor Conversión + Código + Moneda + País @@ -111,15 +122,15 @@
- {item.currency_type_code} + {item.code}
- {item.country_key || '-'} + {item.currency_name} - - {item.conversion_factor?.toFixed(4) || '-'} + + {item.country_description || '-'} {/each} diff --git a/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte index 56db8e62..ea9819ed 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte @@ -3,8 +3,9 @@ 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 { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes"; + import { getTariffFractions, type TariffFraction } from "$lib/api/dashboard/a76/general_catalogs/tariff-fractions"; import { companyStore } from "$lib/stores/company.svelte"; // --- PROPS --- @@ -13,32 +14,23 @@ onSelect }: { open: boolean, - onSelect: (item: { fraction: string; description: string; class_code: string }) => void + onSelect: (item: TariffFraction) => void } = $props(); // --- ESTADO --- - let classes = $state([]); + let items = $state([]); let loading = $state(false); let searchTerm = $state(""); let loaded = $state(false); - // Extraer fracciones únicas - let uniqueFractions = $derived( - Array.from(new Set(classes.map(c => c.fraction))) - .filter(f => f && f.trim()) - .map(fraction => { - const cls = classes.find(c => c.fraction === fraction); - return { - fraction, - description: cls?.description_es || '', - class_code: cls?.class_code || '' - }; - }) - .filter(item => - item.fraction.toLowerCase().includes(searchTerm.toLowerCase()) || - item.description.toLowerCase().includes(searchTerm.toLowerCase()) || - item.class_code.toLowerCase().includes(searchTerm.toLowerCase()) - ) + // 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 @@ -49,41 +41,48 @@ }); async function loadFractions() { - if (!companyStore.activeCompany?.id) return; + if (!companyStore.activeCompany?.id) { + toast.error("No hay empresa seleccionada"); + return; + } loading = true; try { - const response = await classesApi.list({ - company_id: companyStore.activeCompany.id, - page: 1, - page_size: 1000 - }); + 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) { - classes = response.data.items; + items = response.data.items; loaded = true; } else { - console.warn("No se encontraron clases:", response); + console.warn("No se encontraron fracciones:", response); + toast.info("No se encontraron fracciones registradas"); } - } catch (e) { - console.error("Error cargando fracciones:", e); + } 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: { fraction: string; description: string; class_code: string }) { + function handleSelect(item: TariffFraction) { if (onSelect) onSelect(item); open = false; } - + Seleccionar Fracción Arancelaria - Seleccione la fracción arancelaria del catálogo de clases. + Seleccione la fracción arancelaria del catálogo. @@ -91,7 +90,7 @@ @@ -103,7 +102,7 @@

Cargando catálogo...

- {:else if uniqueFractions.length === 0} + {:else if filteredItems.length === 0}

No se encontraron fracciones.

@@ -111,17 +110,21 @@ - Fracción + Código + Fracción + NICO Descripción - Clase - {#each uniqueFractions as item} + {#each filteredItems as item} handleSelect(item)} > + + {item.code} +
@@ -130,14 +133,12 @@
+ + {item.nico || '-'} + {item.description || '-'} - - - {item.class_code} - -
{/each}
@@ -147,7 +148,7 @@
- {uniqueFractions.length} fracciones únicas encontradas + {filteredItems.length} registros encontrados
diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index f88b9461..99c13fe4 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -19,6 +19,7 @@ FileText, Settings, Image as ImageIcon, FolderSearch, UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck, Globe } from 'lucide-svelte'; + import { toast } from "svelte-sonner"; // Stores & APIs import { companyStore } from '$lib/stores/company.svelte'; @@ -78,7 +79,7 @@ weight_type: 'KG', unit_cost: 0, currency_type: '', - currency_key: null, + currency_key: null as string | null, added_value: 0, value_added_type: 'USD', us_fraction: '', @@ -144,6 +145,10 @@ sector: d.fa_data?.sector || '', fraction_type: d.fa_data?.fraction_type || '' }; + // Ensure currency_type is mapped correctly if coming from DB (optional, depending on DB values) + if (d.currency_key === 'MXN') formData.currency_type = 'NA'; + else if (d.currency_key === 'USD') formData.currency_type = 'EX'; + if (d.client_id) await fetchClientName(d.client_id, companyId); if (d.part_class) await fetchClassDesc(d.part_class, companyId); if (d.inv_data?.material_type) await fetchMaterialName(d.inv_data.material_type); @@ -151,6 +156,18 @@ } catch (e) { console.error(e); } finally { loading = false; } } + // --- EFECTOS REACTIVOS --- + $effect(() => { + // Auto-set currency based on type selection + if (formData.currency_type === 'NA') { + formData.currency_key = 'MXN'; + selectedCurrencyName = 'MXN'; + } else if (formData.currency_type === 'EX') { + formData.currency_key = 'USD'; + selectedCurrencyName = 'USD'; + } + }); + // --- HELPERS VISUALES --- async function fetchClientName(clientId: number, companyId: number) { try { @@ -192,11 +209,17 @@ function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; } function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; } function handleCurrencySelect(currency: any) { - formData.currency_type = ''; - formData.currency_key = currency.currency_type_code; - selectedCurrencyName = currency.currency_type_code; + formData.currency_type = ''; // Reset type legacy field + // FIX: Usar 'code' de la API pública currency_types + const code = currency.code || currency.currency_type_code; + formData.currency_key = code; + selectedCurrencyName = code; + } + function handleCountrySelect(country: any) { + // FIX: Asegurar que se asigna la clave correcta + formData.origin_country = country.m3_key || country.country_key; + selectedCountryName = country.description_es; } - function handleCountrySelect(country: any) { formData.origin_country = country.m3_key; selectedCountryName = country.description_es; } function handleFractionSelect(item: any) { formData.fraction = item.fraction; } // --- SUBMIT --- @@ -224,15 +247,28 @@ delete commonData.origin_country; } + console.log("Submitting Part Data:", { + isEdit, + partId, + commonData + }); + if (isEdit && partId) { - await partsApi.update(partId, commonData, activeCompanyId); + // TODO: Verify partId is number/string as expected + const res = await partsApi.update(Number(partId), commonData, activeCompanyId); + console.log("Update Response:", res); + if (res.error) throw new Error(res.error); } else { const result = await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId); + console.log("Create Response:", result); if (result.error) { error = result.error; return; } } + toast.success(isEdit ? "Parte actualizada" : "Parte creada"); goto('/dashboard/goods/parts'); } catch (e: any) { + console.error("Submit Error:", e); error = e.message || 'Error al guardar'; + toast.error(error); } finally { loading = false; } } @@ -527,7 +563,7 @@
- showClassModal = true}/> +
diff --git a/frontend/src/lib/components/dashboard/invoices/columns.ts b/frontend/src/lib/components/dashboard/invoices/columns.ts index 7a22bac0..ead71f11 100644 --- a/frontend/src/lib/components/dashboard/invoices/columns.ts +++ b/frontend/src/lib/components/dashboard/invoices/columns.ts @@ -14,10 +14,36 @@ function formatDate(date?: string | null): string { } export function createColumns( - onSuccess?: () => void, - onDownload?: (invoice: Invoice) => void + onSuccess?: () => void ): ColumnDef[] { return [ + // 0. NUEVA COLUMNA: Checkbox visual (el estado real lo maneja la opacidad) + { + id: "select", + header: ({ table }) => { + return renderSnippet( + createRawSnippet(() => ({ + render: () => `
` + })) + ); + }, + cell: ({ row }) => { + const isSelected = row.getIsSelected(); + + const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => { + const { selected } = getProps(); + return { + render: () => `
+ +
` + }; + }); + + return renderSnippet(checkboxSnippet, { selected: isSelected }); + }, + enableSorting: false, + enableHiding: false, + }, { accessorKey: "operation_type", header: "Operación", @@ -274,8 +300,7 @@ export function createColumns( cell: ({ row }) => { return renderComponent(DataTableActions, { invoice: row.original, - onSuccess, - onDownload + onSuccess }); } } diff --git a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte index 3cd08d74..6144ee75 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table-actions.svelte @@ -3,19 +3,17 @@ import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; // 1. Agregamos FileDown a los imports - import { Ellipsis, Eye, Pencil, Trash2, FileDown } from 'lucide-svelte'; + import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte'; import DetailsDialog from './details-dialog.svelte'; import DeleteDialog from './delete-dialog.svelte'; interface Props { invoice: Invoice; onSuccess?: () => void; - // 2. Definimos la nueva prop (opcional para que no rompa si no se pasa) - onDownload?: (invoice: Invoice) => void; } // 3. Desestructuramos onDownload de los props - let { invoice, onSuccess, onDownload }: Props = $props(); + let { invoice, onSuccess }: Props = $props(); let showDetails = $state(false); let showDelete = $state(false); @@ -44,12 +42,6 @@ Ver Detalles - {#if onDownload} - onDownload(invoice)}> - - Descargar PDF - - {/if} diff --git a/frontend/src/lib/components/dashboard/invoices/data-table.svelte b/frontend/src/lib/components/dashboard/invoices/data-table.svelte index ef98de23..10c61ad2 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table.svelte @@ -13,6 +13,9 @@ loading: boolean; hasMore: boolean; loadMore: () => void; + // Props para selección + selectedId?: number | null; + onRowClick?: (row: TData) => void; }; let { @@ -20,7 +23,9 @@ columns, loading, hasMore, - loadMore + loadMore, + selectedId = null, + onRowClick }: DataTableProps = $props(); const table = createSvelteTable({ @@ -28,7 +33,17 @@ return data; }, columns, - getCoreRowModel: getCoreRowModel() + getCoreRowModel: getCoreRowModel(), + getRowId: (row: any) => row.id?.toString(), // Usar ID para identificar filas + state: { + get rowSelection() { + // Mapear el ID seleccionado al formato que espera TanStack Table + return selectedId ? { [selectedId]: true } : {}; + } + }, + enableRowSelection: true, + enableMultiRowSelection: false, // Solo permitir una selección a la vez + // No necesitamos onRowSelectionChange porque controlamos el estado desde fuera }); let scrollContainer = $state(); @@ -80,7 +95,11 @@ {#each table.getRowModel().rows as row (row.id)} - + onRowClick && onRowClick(row.original)} + > {#each row.getVisibleCells() as cell (cell.id)} + import { Button } from '$lib/components/ui/button'; + import { FileDown, LoaderCircle } from 'lucide-svelte'; + import { toast } from 'svelte-sonner'; + import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices'; + + export let invoiceId: number; + export let companyId: number; + + let processing = false; + + async function startDownload() { + if (processing) return; + + processing = true; + const toastId = toast.loading('Iniciando generación de PDF...'); + + try { + const { task_id } = await invoicesReportsApi.triggerPdfGeneration(invoiceId, companyId); + + const pollInterval = setInterval(async () => { + try { + const statusData = await invoicesReportsApi.getTaskStatus(task_id); + + if (statusData.state === 'SUCCESS') { + clearInterval(pollInterval); + toast.success('Factura generada correctamente', { id: toastId }); + + const { content, file_name, media_type } = statusData.result; + downloadBase64File(content, media_type, file_name); + + processing = false; + + } else if (statusData.state === 'FAILURE') { + clearInterval(pollInterval); + throw new Error(statusData.result || 'Error desconocido'); + + } else if (statusData.state === 'PROCESSING') { + const meta = statusData.result; + if (meta && typeof meta === 'object') { + const current = meta.current || 0; + const total = meta.total || 100; + const progress = Math.round((current / total) * 100); + // Update toast with progress + toast.loading(`Generando PDF: ${progress}%`, { + id: toastId, + description: meta.status || 'Procesando...' + }); + } + } + } catch (err: any) { + clearInterval(pollInterval); + handleError(err, toastId); + } + }, 1000); + + } catch (err: any) { + handleError(err, toastId); + } + } + + function handleError(err: any, toastId: string | number) { + processing = false; + console.error(err); + toast.error('Error al generar PDF: ' + (err.message || 'Error desconocido'), { id: toastId }); + } + + function downloadBase64File(base64Data: string, contentType: string, fileName: string) { + const linkSource = `data:${contentType};base64,${base64Data}`; + const downloadLink = document.createElement("a"); + downloadLink.href = linkSource; + downloadLink.download = fileName; + downloadLink.click(); + } + + + diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte new file mode 100644 index 00000000..d774b867 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -0,0 +1,125 @@ + + + + + + Generando PDF + + Por favor espere mientras se genera su documento. + + + +
+
+ {statusMessage} + {progress}% +
+ + + +
+ {#if isComplete} +
+ + Listo para descargar +
+ {:else if hasError} +
+ + Ocurrió un error +
+ {:else} +
+ +
+ {/if} +
+
+ + + {#if hasError} + + {/if} + +
+
diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c57880c1..7916a006 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -26,7 +26,7 @@ > {#if companyStore.activeCompany?.logo} {companyStore.activeCompany.name} @@ -75,7 +75,7 @@
{#if company.logo} {company.name} diff --git a/frontend/src/lib/components/ui/progress/index.ts b/frontend/src/lib/components/ui/progress/index.ts new file mode 100644 index 00000000..0477d4ff --- /dev/null +++ b/frontend/src/lib/components/ui/progress/index.ts @@ -0,0 +1,2 @@ + +export { default as Progress } from "./progress.svelte"; diff --git a/frontend/src/lib/components/ui/progress/progress.svelte b/frontend/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 00000000..1a45e279 --- /dev/null +++ b/frontend/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + +
+
+
diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index d40b5594..94ceeb78 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -12,19 +12,19 @@ import { redirect, type Cookies } from '@sveltejs/kit'; export function getServerApiUrl(): string { // Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker) let apiUrl = process.env.INTERNAL_API_URL; - + // Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env) if (!apiUrl) { apiUrl = process.env.VITE_API_URL; } - + // Como último recurso, usar el valor de build-time if (!apiUrl) { apiUrl = import.meta.env.VITE_API_URL; // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend'); } - + // Normalizar la URL: asegurar que termine con '/' return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; } @@ -43,8 +43,8 @@ export function getAuthTokens(cookies: Cookies) { * Establece los tokens de autenticación en las cookies */ export function setAuthTokens( - cookies: Cookies, - accessToken: string, + cookies: Cookies, + accessToken: string, refreshToken?: string ) { cookies.set('access_token', accessToken, { @@ -54,7 +54,7 @@ export function setAuthTokens( secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 7 // 7 días }); - + if (refreshToken) { cookies.set('refresh_token', refreshToken, { path: '/', @@ -95,7 +95,7 @@ export async function refreshAccessToken( fetch: typeof globalThis.fetch ): Promise { const { refreshToken } = getAuthTokens(cookies); - + if (!refreshToken) { return null; } @@ -115,10 +115,10 @@ export async function refreshAccessToken( } const data = await response.json(); - + // Actualizar las cookies con los nuevos tokens setAuthTokens(cookies, data.access_token, data.refresh_token); - + return data.access_token; } catch (error) { console.error('🔄 [API] Error al refrescar token:', error); @@ -210,9 +210,9 @@ export async function authenticatedFetch( if (error && typeof error === 'object' && 'status' in error && 'location' in error) { throw error; } - + console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error); - + // Retornar una respuesta de error simulada en lugar de lanzar return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), { status: 500, @@ -253,14 +253,14 @@ export async function validateAuth( if (error && typeof error === 'object' && 'status' in error && 'location' in error) { throw error; } - + console.error('🔐 [API] Error validando autenticación:', error); - + if (redirectOnFail) { clearAuthTokens(cookies); throw redirect(303, redirectOnFail); } - + return null; } } diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 36d0cfa7..cefe2d0d 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -11,9 +11,11 @@ createCompany, updateCompany, getCompany, // Asumiendo que esta función existe en tu API + uploadCompanyLogo, type Company } from '$lib/api/dashboard/a76/general_catalogs/company'; - import { ArrowLeft, LoaderCircle, Save } from 'lucide-svelte'; + import { ArrowLeft, LoaderCircle, Save, Upload } from 'lucide-svelte'; + import { companyStore } from '$lib/stores/company.svelte'; // 1. Lógica de Navegación y Modo const id = $derived($page.params.id); @@ -21,9 +23,10 @@ const title = $derived(isEdit ? "Editar Empresa" : "Nueva Empresa"); let loading = $state(false); + let uploading = $state(false); let error = $state(null); - // 2. Estado Inicial (Reset) + // ... (Initial Data) ... const initialData = { name: '', rfc: '', @@ -43,12 +46,20 @@ is_service_company: false, order_format_type: '', ctpat_svi: '', - trusted_exporter_number: '' + trusted_exporter_number: '', + logo: '', + previous_code: 0, + client_name: '', + subassembly_mode: '', + broker_company: '', + inter_db_name: '', + prevalidator_key: '', + seventh_amendment: false }; let formData = $state({ ...initialData }); - // 3. Efecto para "Heredar" datos o Limpiar + // ... (Fetch Data) ... $effect(() => { if (isEdit) { fetchData(id); @@ -61,7 +72,6 @@ async function fetchData(companyId: string) { loading = true; try { - // Nota: Aquí usamos tu API para traer la info de una sola empresa const response = await getCompany(Number(companyId)); if (response.data) { const item = response.data; @@ -84,7 +94,15 @@ is_service_company: item.is_service_company || false, order_format_type: item.order_format_type || '', ctpat_svi: item.ctpat_svi || '', - trusted_exporter_number: item.trusted_exporter_number || '' + trusted_exporter_number: item.trusted_exporter_number || '', + logo: item.logo || '', + previous_code: item.previous_code || 0, + client_name: item.client_name || '', + subassembly_mode: item.subassembly_mode || '', + broker_company: item.broker_company || '', + inter_db_name: item.inter_db_name || '', + prevalidator_key: item.prevalidator_key || '', + seventh_amendment: item.seventh_amendment || false }; } } catch (e) { @@ -96,6 +114,31 @@ const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value); + async function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + if (!isEdit) { + alert("Primero debes guardar la empresa antes de subir un logo."); + return; + } + + uploading = true; + try { + const res = await uploadCompanyLogo(Number(id), file); + if (res.data) { + formData.logo = res.data.path; + } else if (res.error) { + alert("Error al subir imagen: " + res.error); + } + } catch (err) { + alert("Error al intentar subir la imagen"); + } finally { + uploading = false; + } + } + async function handleSubmit() { error = null; loading = true; @@ -122,7 +165,15 @@ manufacturer_id: clean(formData.manufacturer_id), order_format_type: clean(formData.order_format_type), ctpat_svi: clean(formData.ctpat_svi), - trusted_exporter_number: clean(formData.trusted_exporter_number) + trusted_exporter_number: clean(formData.trusted_exporter_number), + logo: clean(formData.logo), + previous_code: Number(formData.previous_code) || 0, + client_name: clean(formData.client_name), + subassembly_mode: clean(formData.subassembly_mode), + broker_company: clean(formData.broker_company), + inter_db_name: clean(formData.inter_db_name), + prevalidator_key: clean(formData.prevalidator_key), + seventh_amendment: formData.seventh_amendment }; const response = isEdit @@ -131,6 +182,27 @@ if (response.error) throw new Error(response.error); + // Update global store if we are editing the active company + if (response.data) { + const updatedComp = response.data; + // We verify if we are editing the currently active company + if (companyStore.activeCompany?.id === updatedComp.id) { + // We update the store. + // IMPORTANT: To force image refresh, we might need a cache buster in the sidebar, + // but updating the store object is Step 1. + companyStore.setActiveCompany({ + id: updatedComp.id, + name: updatedComp.name || '', + rfc: updatedComp.rfc || '', + logo: updatedComp.logo || '', + tenant_id: updatedComp.tenant_id + }); + + // Force reload of company list to ensure integrity + companyStore.loadCompanies(); + } + } + goto('/dashboard/general_catalogs/company_information'); } catch (e: any) { error = e.message || 'Error al guardar'; @@ -181,6 +253,37 @@
+
+
+ +
+ + {#if isEdit} +
+ + +
+ {/if} +
+

Sube una imagen para obtener su ruta local.

+
+
+ + +
+
@@ -204,6 +307,16 @@ +
+
+ + +
+
+ + +
+
@@ -243,8 +356,24 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index b77fd732..32938329 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -5,10 +5,12 @@ import { Plus, RefreshCw, Package } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { partsApi, type Part } from '$lib/api/dashboard/a76/parts'; + import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; // Estado de la lista de partes let parts = $state([]); + let clientsMap = $state>({}); // Mapa ID -> Nombre let selectedPart = $state(null); let isLoading = $state(false); let searchPartNumber = $state(''); @@ -28,9 +30,11 @@ (p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) || (p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false); - // Filtro por cliente + // Filtro por cliente (Busca en nombre o ID) + const clientName = clientsMap[p.client_id] || ''; const matchesClient = !searchClient || - (p.client_id?.toString().includes(searchClient) ?? false); + (p.client_id?.toString().includes(searchClient) ?? false) || + clientName.toLowerCase().includes(searchClient.toLowerCase()); // Filtro por clase const matchesClass = !searchClass || @@ -44,10 +48,40 @@ $effect(() => { const companyId = companyStore.activeCompany?.id; if (companyId) { - loadParts(); + loadData(); } }); + async function loadData() { + await Promise.all([loadParts(), loadClients()]); + } + + async function loadClients() { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + + try { + // Fetch all clients/providers to ensure we map "both" types as well + const response = await clientsProvidersApi.list( + companyId, + 1, + 1000 + ); + + const data = (response as any).data || response; + const items = data.items || []; + + const map: Record = {}; + items.forEach((c: any) => { + map[c.id] = c.name; + }); + clientsMap = map; + + } catch (e) { + console.error("Error cargando clientes:", e); + } + } + async function loadParts() { const companyId = companyStore.activeCompany?.id; if (!companyId) { @@ -78,17 +112,35 @@ } async function handleRefresh() { - await loadParts(); + await loadData(); toast.success('Partes actualizadas'); } - function handleDelete() { + + async function handleDelete() { if (!selectedPart) { toast.error('Selecciona una parte para borrar'); return; } - // TODO: Implementar eliminación - toast.info('Función de eliminación pendiente'); + + const confirmed = window.confirm(`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`); + if (!confirmed) return; + + isLoading = true; + try { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + + await partsApi.delete(selectedPart.id, companyId); + toast.success('Parte eliminada exitosamente'); + selectedPart = null; + await loadData(); + } catch (e) { + console.error("Error al eliminar:", e); + toast.error('Error al eliminar la parte'); + } finally { + isLoading = false; + } } @@ -135,7 +187,7 @@
@@ -215,7 +267,12 @@ {part.description_spanish || ''} - {part.client_id || '-'} + +
+ {clientsMap[part.client_id] || 'Cargando...'} + ID: {part.client_id} +
+ {#if part.part_class} @@ -263,7 +320,7 @@
- {selectedPart.client_id || '-'} + {clientsMap[selectedPart.client_id] || selectedPart.client_id}
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 51212163..90031480 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -12,10 +12,11 @@ import type { PageData } from './$types'; import { browser } from '$app/environment'; import { companyStore } from '$lib/stores/company.svelte'; - import { Plus, RefreshCw } from 'lucide-svelte'; + import { Plus, RefreshCw, FileDown, RotateCcw } from 'lucide-svelte'; // IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones import { toast } from "svelte-sonner"; + import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte'; // Los datos iniciales vienen del servidor let { data }: { data: PageData } = $props(); @@ -151,6 +152,24 @@ let loading = $state(false); let hasMore = $derived(allItems.length < totalItems); let error = $state(data.error || null); + + // Estado para selección de fila + let selectedInvoiceId = $state(null); + + function handleRowClick(invoice: Invoice) { + // Si ya está seleccionado, lo deseleccionamos (opcional, si queremos permitir toggle) + // O simplemente lo seleccionamos. Aquí implemento toggle. + if (selectedInvoiceId === invoice.id) { + selectedInvoiceId = null; + } else { + selectedInvoiceId = invoice.id; + } + console.log('Selected Invoice ID:', selectedInvoiceId); + } + + const selectedInvoice = $derived( + selectedInvoiceId ? allItems.find(i => i.id === selectedInvoiceId) : null + ); async function loadMore() { if (loading || !hasMore) return; @@ -312,83 +331,72 @@ } } + // Estado para el diálogo de progreso + let showProgressDialog = $state(false); + let currentTaskId = $state(null); + // Utilidad para convertir Base64 a Blob -function base64ToBlob(base64: string, type: string) { - const binStr = atob(base64); - const len = binStr.length; - const arr = new Uint8Array(len); - for (let i = 0; i < len; i++) { - arr[i] = binStr.charCodeAt(i); + function base64ToBlob(base64: string, type: string) { + const binStr = atob(base64); + const len = binStr.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = binStr.charCodeAt(i); + } + return new Blob([arr], { type: type }); } - return new Blob([arr], { type: type }); -} -async function handleDownloadPdf(invoice: any) { - const toastId = toast.loading("Iniciando generación de PDF..."); - - try { - // 1. Trigger: Iniciar la tarea en Celery - const { task_id } = await invoicesReportsApi.triggerPdfGeneration( - invoice.id, - companyStore.activeCompany.id - ); + async function handleDownloadPdf(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } - toast.loading("Procesando PDF en segundo plano...", { id: toastId }); + try { + // 1. Trigger: Iniciar la tarea en Celery + const { task_id } = await invoicesReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); - // 2. Polling: Loop para verificar estado - let intentos = 0; - const maxIntentos = 30; // Timeout de seguridad (aprox 60 segs) - - const interval = setInterval(async () => { - intentos++; - try { - const statusData = await invoicesReportsApi.getTaskStatus(task_id); + // 2. Abrir diálogo de progreso + currentTaskId = task_id; + showProgressDialog = true; - if (statusData.state === 'SUCCESS') { - clearInterval(interval); - - const result = statusData.result; // Tu dict del backend - - if (result.status === 'success') { - // 3. Convertir Base64 a Blob y Descargar - const blob = base64ToBlob(result.content, result.media_type); - - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = result.file_name; // Nombre que viene del worker - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga"); + } + } - toast.success("PDF Descargado", { id: toastId }); - } else { - toast.error("Error al generar el archivo", { id: toastId }); - } - } - else if (statusData.state === 'FAILURE') { - clearInterval(interval); - toast.error("Falló la generación del PDF", { id: toastId }); - } - else if (intentos >= maxIntentos) { - clearInterval(interval); - toast.error("Tiempo de espera agotado", { id: toastId }); - } - // Si es PENDING o STARTED, el intervalo continúa... - - } catch (err) { - console.error(err); - clearInterval(interval); // Detener en caso de error de red - toast.error("Error de conexión", { id: toastId }); + function onPdfComplete(result: any) { + // Esta función se llama cuando el diálogo reporta SUCCESS + try { + if (result.status === 'success') { + const blob = base64ToBlob(result.content, result.media_type); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.file_name; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success("PDF Descargado exitosamente"); + } else { + toast.error("El worker reportó un error: " + (result.message || "Desconocido")); } - }, 2000); // Consultar cada 2 segundos - - } catch (error) { - console.error(error); - toast.error("No se pudo iniciar la descarga", { id: toastId }); + } catch (e) { + console.error("Error al procesar descarga:", e); + toast.error("Error al procesar el archivo descargado"); + } finally { + // Cerrar diálogo después de un breve momento + setTimeout(() => { + showProgressDialog = false; + currentTaskId = null; + }, 1000); + } } -} function handleCreateClick() { const params = new URLSearchParams(window.location.search); @@ -443,8 +451,13 @@ async function handleDownloadPdf(invoice: any) { ); }); + function closeProgressDialog() { + showProgressDialog = false; + currentTaskId = null; + } + // --- AQUÍ PASAMOS LA FUNCIÓN DE DESCARGA A LAS COLUMNAS --- - const columns = createColumns(handleSuccess, handleDownloadPdf); + const columns = createColumns(handleSuccess);
@@ -556,7 +569,38 @@ async function handleDownloadPdf(invoice: any) { {loading} {hasMore} {loadMore} + selectedId={selectedInvoiceId} + onRowClick={handleRowClick} /> + + + + + +
+
+ +
+ + + +
+
+
\ No newline at end of file