feat: replace SVG icons with Lucide icons in reference data pages

- Updated the "Nueva Sección" button in customs_sections, customs_warehouses, incoterms, invoice_types, material_types, payment_methods, pedimento_codes, pedimento_regimens, sectors, states, transport_modes, and transport_types pages to use the Plus icon from Lucide.
- Updated the "Actualizar" button in the same pages to use the RefreshCw icon from Lucide.
- Added a new edit dialog component for customs brokers with a comprehensive form for editing broker details, including validation and loading states.
This commit is contained in:
2025-11-24 16:43:53 -06:00
parent cdc3788a05
commit dcd42583d9
63 changed files with 1232 additions and 1810 deletions

View File

@@ -1,67 +0,0 @@
"""Add timestamp fields to classes table
Revision ID: add_timestamps_classes
Revises: 7937209f9718
Create Date: 2025-11-16 00:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_timestamps_classes'
down_revision: Union[str, None] = '7937209f9718'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add created_at column with default value
op.execute("""
ALTER TABLE a76.classes
ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
""")
# Add updated_at column with default value
op.execute("""
ALTER TABLE a76.classes
ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
""")
# Add deleted_at column (nullable for soft deletes)
op.execute("""
ALTER TABLE a76.classes
ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE
""")
# Create trigger to auto-update updated_at
op.execute("""
CREATE OR REPLACE FUNCTION a76.update_classes_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
""")
op.execute("""
CREATE TRIGGER update_classes_updated_at
BEFORE UPDATE ON a76.classes
FOR EACH ROW
EXECUTE FUNCTION a76.update_classes_updated_at();
""")
def downgrade() -> None:
# Drop trigger and function
op.execute("DROP TRIGGER IF EXISTS update_classes_updated_at ON a76.classes")
op.execute("DROP FUNCTION IF EXISTS a76.update_classes_updated_at()")
# Drop columns
op.execute("ALTER TABLE a76.classes DROP COLUMN IF EXISTS deleted_at")
op.execute("ALTER TABLE a76.classes DROP COLUMN IF EXISTS updated_at")
op.execute("ALTER TABLE a76.classes DROP COLUMN IF EXISTS created_at")

View File

@@ -4,7 +4,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from decimal import Decimal
from typing import Literal, Optional
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@@ -119,7 +119,7 @@ class ClientProviderCreateDTO(BaseModel):
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(
@@ -162,7 +162,7 @@ class ClientProviderUpdateDTO(BaseModel):
is_national_provider: Optional[str] = Field(
None, max_length=2, description="Is national provider"
)
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
# Nested DTOs
address: Optional[ClientProviderAddressDTO] = Field(
@@ -194,7 +194,7 @@ class ClientProviderResponseDTO(BaseModel):
position: Optional[str] = None
incoterm: Optional[str] = None
is_national_provider: Optional[str] = None
enabled_disabled: Optional[int] = None
is_active: Optional[bool] = None
tenant_id: int
company_id: int
@@ -215,7 +215,7 @@ class ClientProviderBasicDTO(BaseModel):
short_name: Optional[str] = None
rfc: Optional[str] = None
client_or_provider: Optional[str] = None
enabled_disabled: Optional[int] = None
is_active: Optional[bool] = None
class Config:
from_attributes = True
@@ -231,3 +231,15 @@ class ClientProviderListDTO(BaseModel):
class Config:
from_attributes = True
class ClientProviderPaginatedResponseDTO(BaseModel):
"""DTO para respuesta paginada de clientes/proveedores"""
items: List[ClientProviderResponseDTO]
total: int
page: int
page_size: int
class Config:
from_attributes = True

View File

@@ -15,7 +15,8 @@ from sqlalchemy import (
PrimaryKeyConstraint,
SmallInteger,
String,
Enum as PgEnum
Enum as PgEnum,
Boolean
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from enum import Enum
@@ -60,8 +61,8 @@ class ClientProvider(Base, TenantScopedMixin):
responsible: Mapped[Optional[str]] = mapped_column(String(80))
position: Mapped[Optional[str]] = mapped_column(String(30))
incoterm: Mapped[Optional[str]] = mapped_column(String(19))
is_national_provider: Mapped[Optional[str]] = mapped_column(String(2))
enabled_disabled: Mapped[Optional[int]] = mapped_column(SmallInteger)
is_national_provider: Mapped[Optional[bool]] = mapped_column(Boolean)
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
# Relationships
address: Mapped[Optional["ClientProviderAddress"]] = relationship(

View File

@@ -9,136 +9,57 @@ from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .models import ClientOrProviderEnum
from .dto import (
ClientProviderBasicDTO,
ClientProviderCreateDTO,
ClientProviderResponseDTO,
ClientProviderUpdateDTO,
ClientProviderPaginatedResponseDTO,
)
from .service import ClientProviderService
from .models import ClientProvider
# Create base CRUD router using TenantCRUDRoutes factory
base_router = TenantCRUDRoutes(
service=ClientProviderService,
create_schema=ClientProviderCreateDTO,
update_schema=ClientProviderUpdateDTO,
response_schema=ClientProviderResponseDTO,
prefix="",
tags=[],
resource_name="Client/Provider",
id_name="id", # Using numeric ID
enable_list=True, # Enable GET /clients-providers with pagination
enable_filters=True, # Enable filtering
default_page_size=50,
max_page_size=100,
).router
# Create main router to add custom endpoints
router = APIRouter(prefix="/clients-providers")
# Include base CRUD routes
router.include_router(base_router, prefix="")
# Custom endpoints
@router.get("/clients", response_model=List[ClientProviderBasicDTO])
async def get_clients_only(
@router.get("/", response_model=ClientProviderPaginatedResponseDTO)
async def get_clients_and_providers(
company_id: int = Query(..., description="Company ID"),
type: Optional[ClientOrProviderEnum] = Query(
None, description="Type of entity (client or provider)"
),
active: Optional[bool] = Query(None, description="Active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get only clients (client_or_provider = 'client')"""
"""Get clients and providers"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
clients = (
db.query(ClientProvider)
.filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
ClientProvider.client_or_provider == "client"
)
.offset(skip)
.limit(limit)
.all()
query = db.query(ClientProvider).filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
return [ClientProviderBasicDTO.model_validate(c) for c in clients]
if type is not None:
query = query.filter(ClientProvider.client_or_provider == type)
@router.get("/providers", response_model=List[ClientProviderBasicDTO])
async def get_providers_only(
company_id: int = Query(..., description="Company ID"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get only providers (client_or_provider = 'provider')"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
providers = (
db.query(ClientProvider)
.filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
ClientProvider.client_or_provider == "provider"
)
.offset(skip)
.limit(limit)
.all()
)
return [ClientProviderBasicDTO.model_validate(p) for p in providers]
if active is not None:
query = query.filter(ClientProvider.is_active == active)
total = query.count()
clients = query.offset(skip).limit(limit).all()
@router.get("/search/rfc/{rfc}", response_model=List[ClientProviderBasicDTO])
async def search_by_rfc(
rfc: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Search clients/providers by RFC"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
clients = (
db.query(ClientProvider)
.filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
ClientProvider.rfc.ilike(f"%{rfc}%")
)
.all()
)
return [ClientProviderBasicDTO.model_validate(c) for c in clients]
@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO)
async def toggle_clients_and_providers_status(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Toggle client/provider enabled/disabled status"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
raise HTTPException(status_code=404, detail="Client/Provider not found")
# Toggle status (1 = enabled, 0 = disabled)
client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0
try:
db.commit()
db.refresh(client)
return client
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail="Error updating status")
return {
"items": [ClientProviderResponseDTO.model_validate(c) for c in clients],
"total": total,
"page": (skip // limit) + 1,
"page_size": limit,
}
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
@@ -150,9 +71,59 @@ async def get_clients_and_providers_basic_info(
):
"""Get basic information for a client/provider (without address and programs)"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return ClientProviderBasicDTO.model_validate(client)
@router.post("/", response_model=ClientProviderResponseDTO)
async def create_client_provider(
client_data: ClientProviderCreateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
return ClientProviderService.create(db, client_data, tenant_id, company_id)
@router.patch("/{client_id}", response_model=ClientProviderResponseDTO)
async def update_client_provider(
client_id: int,
client_data: ClientProviderUpdateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Update a client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
client = ClientProviderService.update(
db, client_id, tenant_id, company_id, client_data
)
if not client:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return client
@router.delete("/{client_id}", response_model=bool)
async def delete_client_provider(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a client/provider"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
success = ClientProviderService.delete(db, client_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="Client/Provider not found")
return success

View File

@@ -61,13 +61,17 @@ class ClientProviderService:
)
if filters.get("status"):
enabled = 1 if filters["status"] == "enabled" else 0
query = query.filter(ClientProvider.enabled_disabled == enabled)
query = query.filter(ClientProvider.is_active == enabled)
total = query.count()
clients = query.options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).offset(skip).limit(limit).all()
clients = (
query.options(
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.offset(skip)
.limit(limit)
.all()
)
return clients, total
@@ -79,8 +83,7 @@ class ClientProviderService:
return (
db.query(ClientProvider)
.options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
joinedload(ClientProvider.address), joinedload(ClientProvider.programs)
)
.filter(
ClientProvider.id == client_id,
@@ -102,9 +105,7 @@ class ClientProviderService:
# Create main client/provider
data_dict = client_data.model_dump(exclude={"address", "programs"})
db_client = ClientProvider(
**data_dict,
tenant_id=tenant_id,
company_id=company_id
**data_dict, tenant_id=tenant_id, company_id=company_id
)
db.add(db_client)
@@ -112,8 +113,8 @@ class ClientProviderService:
# Create address if provided
if client_data.address:
db_address = ClientProviderAddress(
tenant_id=tenant_id,
db_address = ClientProviderAddress(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
**client_data.address.model_dump(exclude_unset=True),
@@ -122,7 +123,7 @@ class ClientProviderService:
# Create programs if provided
if client_data.programs:
db_programs = ClientProviderPrograms(
db_programs = ClientProviderPrograms(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
@@ -210,9 +211,7 @@ class ClientProviderService:
)
@staticmethod
def delete(
db: Session, client_id: int, tenant_id: int, company_id: int
) -> bool:
def delete(db: Session, client_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a client/provider"""
client = ClientProviderService.get_by_id(db, client_id, tenant_id, company_id)
if not client:
@@ -351,7 +350,7 @@ class ClientProviderService:
)
if enabled_only:
query = query.filter(ClientProvider.enabled_disabled == 1)
query = query.filter(ClientProvider.is_active == 1)
# Contar total
total = query.count()
@@ -478,59 +477,4 @@ class ClientProviderService:
logger.error(f"Error deleting client/provider {client_id}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting client/provider"
)
def get_clients_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo clientes (client)"""
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "client"
)
clients = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def get_providers_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo proveedores (provider)"""
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "provider"
)
providers = query.offset(skip).limit(limit).all()
return [
ClientProviderBasicDTO.model_validate(provider) for provider in providers
]
def search_by_rfc(self, rfc: str) -> List[ClientProviderBasicDTO]:
"""Busca clientes/proveedores por RFC"""
clients = (
self.db.query(ClientProvider)
.filter(ClientProvider.rfc.ilike(f"%{rfc}%"))
.all()
)
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
def toggle_status(self, client_id: str) -> Optional[ClientProviderResponseDTO]:
"""Cambia el estado habilitado/deshabilitado"""
client = (
self.db.query(ClientProvider)
.filter(ClientProvider.client_id == client_id)
.first()
)
if not client:
return None
# Toggle status (1 = habilitado, 0 = deshabilitado)
client.enabled_disabled = 1 if client.enabled_disabled == 0 else 0
try:
self.db.commit()
logger.info(
f"Client/Provider status toggled: {client_id} -> {client.enabled_disabled}"
)
return self._get_client_with_relations(client_id)
except Exception as e:
self.db.rollback()
logger.error(f"Error toggling status for {client_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating status")
)

View File

@@ -67,7 +67,7 @@ class PartCreateDTO(BaseModel):
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
creation_date: Optional[int] = Field(None, description="Creation date")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
@@ -132,7 +132,7 @@ class PartUpdateDTO(BaseModel):
added_value: Optional[Decimal] = Field(None, description="Added value")
# Status and media
enabled_disabled: Optional[int] = Field(None, description="Enabled/Disabled status")
is_active: Optional[bool] = Field(None, description="Enabled/Disabled status")
part_photo: Optional[str] = Field(
None, max_length=255, description="Part photo URL"
)
@@ -178,7 +178,7 @@ class PartResponseDTO(BaseModel):
added_value: Optional[Decimal] = None
# Status and dates
enabled_disabled: Optional[int] = None
is_active: Optional[bool] = None
creation_date: Optional[int] = None
modification_date: Optional[int] = None
modification_date_iso: Optional[datetime] = None
@@ -200,7 +200,7 @@ class PartBasicDTO(BaseModel):
part_class: Optional[str] = None
unit_cost: Optional[Decimal] = None
currency_key: Optional[str] = None
enabled_disabled: Optional[int] = None
is_active: Optional[bool] = None
class Config:
from_attributes = True

View File

@@ -16,6 +16,7 @@ from sqlalchemy import (
SmallInteger,
String,
UniqueConstraint,
Boolean,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -90,7 +91,7 @@ class Part(Base, TenantScopedMixin):
added_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
# Status and dates
enabled_disabled: Mapped[Optional[int]] = mapped_column(SmallInteger)
is_active: Mapped[Optional[bool]] = mapped_column(Boolean)
creation_date: Mapped[Optional[int]] = mapped_column() # FECHACREACIONPARTE
modification_date: Mapped[Optional[int]] = mapped_column() # FECHAMODIFICA
modification_date_iso: Mapped[Optional[datetime]] = (

View File

@@ -348,7 +348,7 @@ async def get_part_basic_info(
part_class=part.part_class,
unit_cost=part.unit_cost,
currency_key=part.currency_key,
enabled_disabled=part.enabled_disabled,
is_active=part.is_active,
)

View File

@@ -221,7 +221,7 @@ class PartService:
return None
# Toggle status (assuming 1 = enabled, 0 = disabled)
db_part.enabled_disabled = 1 if db_part.enabled_disabled == 0 else 0
db_part.is_active = 1 if db_part.is_active == 0 else 0
db.commit()
db.refresh(db_part)
@@ -257,8 +257,8 @@ class PartService:
)
# Partes habilitadas vs deshabilitadas
enabled_parts = db.query(Part).filter(Part.enabled_disabled == 1).count()
disabled_parts = db.query(Part).filter(Part.enabled_disabled == 0).count()
enabled_parts = db.query(Part).filter(Part.is_active == 1).count()
disabled_parts = db.query(Part).filter(Part.is_active == 0).count()
return {
"total_parts": total_parts,

View File

@@ -16,15 +16,21 @@ def list_code_pedimento_regimens(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
code: str = Query(None, description="Filter by code"),
regime: str = Query(None, description="Filter by regime"),
type: str = Query(None, description="Filter by type"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
skip = (page - 1) * page_size
query = db.query(CodePedimentoRegimen)
if code is not None:
query = query.filter(CodePedimentoRegimen.pedimento_code == code)
if regime is not None:
query = query.filter(CodePedimentoRegimen.regime == regime)
if type is not None:
query = query.filter(CodePedimentoRegimen.type == type)
items = query.offset(skip).limit(page_size).all()
total = query.count()
return {