feat: Implement client and provider management dashboard

- Added data table component for displaying clients and providers with infinite scroll functionality.
- Created dropdown actions for each client/provider including copy ID, copy RFC, view details, edit, toggle status, and delete.
- Implemented dialogs for creating, editing, viewing details, and deleting clients/providers.
- Integrated API calls for fetching, creating, editing, deleting, and toggling status of clients/providers.
- Enhanced error handling and loading states for better user experience.
- Updated server-side logic to handle pagination and company selection for client/provider data retrieval.
This commit is contained in:
2025-11-12 16:08:42 -06:00
parent 67a309912c
commit 6225122832
32 changed files with 1974 additions and 543 deletions

View File

@@ -14,7 +14,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class QClasses(Base, TenantScopedMixin):
__tablename__ = "q_classes" # QClases
__tablename__ = "fa_classes" # QClases
__table_args__ = (
PrimaryKeyConstraint("id", name="qclases_pk"),
ForeignKeyConstraint(

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class SClasses(Base, TenantScopedMixin):
__tablename__ = "s_classes" # SClases
__tablename__ = "inv_classes" # SClases
__table_args__ = (
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_sclasses_tenants"

View File

@@ -37,7 +37,7 @@ class Class(Base, TenantScopedMixin):
["company_id"], ["a76.company.id"], name="fk_classes_company"
),
ForeignKeyConstraint(
["client_id"], ["a76.client_provider.id"], name="fk_classes_client"
["client_id"], ["a76.clients_and_providers.id"], name="fk_classes_client"
),
ForeignKeyConstraint(
["material_key"],
@@ -48,7 +48,7 @@ class Class(Base, TenantScopedMixin):
"tenant_id",
"company_id",
"class_code",
name="uq_classes_client_id_class_code",
name="ufa_classes_client_id_class_code",
),
{"schema": "a76"},
)

View File

@@ -4,7 +4,7 @@ Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from decimal import Decimal
from typing import Optional
from typing import Literal, Optional
from pydantic import BaseModel, Field
@@ -100,8 +100,8 @@ class ClientProviderCreateDTO(BaseModel):
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(
None, max_length=1, description="Client or provider"
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
None, description="Client or provider"
)
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(
@@ -143,8 +143,8 @@ class ClientProviderUpdateDTO(BaseModel):
short_name: Optional[str] = Field(None, max_length=10, description="Short name")
rfc: Optional[str] = Field(None, max_length=30, description="RFC")
curp: Optional[str] = Field(None, max_length=19, description="CURP")
client_or_provider: Optional[str] = Field(
None, max_length=1, description="Client or provider"
client_or_provider: Optional[Literal["client", "provider", "both"]] = Field(
None, description="Client or provider"
)
linking: Optional[str] = Field(None, max_length=1, description="Linking")
transform_subassembly: Optional[str] = Field(

View File

@@ -15,8 +15,15 @@ from sqlalchemy import (
PrimaryKeyConstraint,
SmallInteger,
String,
Enum as PgEnum
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from enum import Enum
class ClientOrProviderEnum(str, Enum):
CLIENT = "client"
PROVIDER = "provider"
BOTH = "both"
class ClientProvider(Base, TenantScopedMixin):
@@ -24,14 +31,14 @@ class ClientProvider(Base, TenantScopedMixin):
Modelo para la tabla GClientesPro - Información de clientes y proveedores
"""
__tablename__ = "client_provider"
__tablename__ = "clients_and_providers"
__table_args__ = (
PrimaryKeyConstraint("id", name="client_provider_pkey"),
PrimaryKeyConstraint("id", name="clients_and_providers_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_tenant"
["tenant_id"], ["a76.tenants.id"], name="fk_clients_and_providers_tenant"
),
ForeignKeyConstraint(
["company_id"], ["a76.company.id"], name="fk_client_provider_company"
["company_id"], ["a76.company.id"], name="fk_clients_and_providers_company"
),
{"schema": "a76"},
)
@@ -40,14 +47,12 @@ class ClientProvider(Base, TenantScopedMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Basic information
type_nat_foreign: Mapped[Optional[str]] = mapped_column(
String(1)
) # TIPO NACIONAL/EXTRANJERO
type_nat_foreign: Mapped[Optional[str]] = mapped_column(String(1)) # TIPO NACIONAL/EXTRANJERO
name: Mapped[Optional[str]] = mapped_column(String(256))
short_name: Mapped[Optional[str]] = mapped_column(String(10))
rfc: Mapped[Optional[str]] = mapped_column(String(30))
curp: Mapped[Optional[str]] = mapped_column(String(19))
client_or_provider: Mapped[Optional[str]] = mapped_column(String(1))
client_or_provider: Mapped[ClientOrProviderEnum] = mapped_column(PgEnum(ClientOrProviderEnum, name="entity_client_or_provider", create_type=True, native_enum=True),nullable=False)
linking: Mapped[Optional[str]] = mapped_column(String(1))
transform_subassembly: Mapped[Optional[str]] = mapped_column(String(1))
extra_information: Mapped[Optional[str]] = mapped_column(String(399))
@@ -60,10 +65,10 @@ class ClientProvider(Base, TenantScopedMixin):
# Relationships
address: Mapped[Optional["ClientProviderAddress"]] = relationship(
back_populates="client_provider", uselist=False, cascade="all, delete-orphan"
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
)
programs: Mapped[Optional["ClientProviderPrograms"]] = relationship(
back_populates="client_provider", uselist=False, cascade="all, delete-orphan"
back_populates="clients_and_providers", uselist=False, cascade="all, delete-orphan"
)
@@ -72,17 +77,17 @@ class ClientProviderAddress(Base, TenantScopedMixin):
Modelo para la tabla GClientesPro_Direccion - Dirección de clientes y proveedores
"""
__tablename__ = "client_provider_address"
__tablename__ = "clients_and_providers_address"
__table_args__ = (
PrimaryKeyConstraint("id", name="client_provider_address_pkey"),
PrimaryKeyConstraint("id", name="clients_and_providers_address_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_address_tenant"
["tenant_id"], ["a76.tenants.id"], name="fk_clients_and_providers_address_tenant"
),
ForeignKeyConstraint(
["client_id"],
["a76.client_provider.id"],
["a76.clients_and_providers.id"],
ondelete="CASCADE",
name="fk_client_provider_address_client",
name="fk_clients_and_providers_address_client",
),
{"schema": "a76"},
)
@@ -90,7 +95,7 @@ class ClientProviderAddress(Base, TenantScopedMixin):
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.client_provider.id", ondelete="CASCADE")
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
)
# Address information
@@ -110,7 +115,7 @@ class ClientProviderAddress(Base, TenantScopedMixin):
reference: Mapped[Optional[str]] = mapped_column(String(250))
# Relationship
client_provider: Mapped["ClientProvider"] = relationship(back_populates="address")
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="address")
class ClientProviderPrograms(Base, TenantScopedMixin):
@@ -118,17 +123,17 @@ class ClientProviderPrograms(Base, TenantScopedMixin):
Modelo para la tabla GClientesPro_Programas - Programas de clientes y proveedores
"""
__tablename__ = "client_provider_programs"
__tablename__ = "clients_and_providers_programs"
__table_args__ = (
PrimaryKeyConstraint("id", name="client_provider_programs_pkey"),
PrimaryKeyConstraint("id", name="clients_and_providers_programs_pkey"),
ForeignKeyConstraint(
["tenant_id"], ["a76.tenants.id"], name="fk_client_provider_programs_tenant"
["tenant_id"], ["a76.tenants.id"], name="fk_clients_and_providers_programs_tenant"
),
ForeignKeyConstraint(
["client_id"],
["a76.client_provider.id"],
["a76.clients_and_providers.id"],
ondelete="CASCADE",
name="fk_client_provider_programs_client",
name="fk_clients_and_providers_programs_client",
),
{"schema": "a76"},
)
@@ -136,7 +141,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin):
# Primary key (foreign key)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int] = mapped_column(
Integer, ForeignKey("a76.client_provider.id", ondelete="CASCADE")
Integer, ForeignKey("a76.clients_and_providers.id", ondelete="CASCADE")
)
# Program information
@@ -162,4 +167,4 @@ class ClientProviderPrograms(Base, TenantScopedMixin):
autse_number: Mapped[Optional[str]] = mapped_column(String(300))
# Relationship
client_provider: Mapped["ClientProvider"] = relationship(back_populates="programs")
clients_and_providers: Mapped["ClientProvider"] = relationship(back_populates="programs")

View File

@@ -25,7 +25,7 @@ base_router = TenantCRUDRoutes(
create_schema=ClientProviderCreateDTO,
update_schema=ClientProviderUpdateDTO,
response_schema=ClientProviderResponseDTO,
prefix="/clients-providers",
prefix="",
tags=[],
resource_name="Client/Provider",
id_name="id", # Using numeric ID
@@ -51,7 +51,7 @@ async def get_clients_only(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get only clients (client_or_provider = 'C')"""
"""Get only clients (client_or_provider = 'client')"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
clients = (
@@ -59,7 +59,7 @@ async def get_clients_only(
.filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
ClientProvider.client_or_provider == "C"
ClientProvider.client_or_provider == "client"
)
.offset(skip)
.limit(limit)
@@ -76,7 +76,7 @@ async def get_providers_only(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get only providers (client_or_provider = 'P')"""
"""Get only providers (client_or_provider = 'provider')"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
providers = (
@@ -84,7 +84,7 @@ async def get_providers_only(
.filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
ClientProvider.client_or_provider == "P"
ClientProvider.client_or_provider == "provider"
)
.offset(skip)
.limit(limit)
@@ -116,7 +116,7 @@ async def search_by_rfc(
@router.patch("/{client_id}/toggle-status", response_model=ClientProviderResponseDTO)
async def toggle_client_provider_status(
async def toggle_clients_and_providers_status(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
@@ -142,7 +142,7 @@ async def toggle_client_provider_status(
@router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO)
async def get_client_provider_basic_info(
async def get_clients_and_providers_basic_info(
client_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),

View File

@@ -112,18 +112,20 @@ class ClientProviderService:
# Create address if provided
if client_data.address:
db_address = ClientProviderAddress(
db_address = ClientProviderAddress(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
tenant_id=tenant_id,
**client_data.address.model_dump(exclude_unset=True),
)
db.add(db_address)
# Create programs if provided
if client_data.programs:
db_programs = ClientProviderPrograms(
client_id=db_client.id,
db_programs = ClientProviderPrograms(
tenant_id=tenant_id,
company_id=company_id,
client_id=db_client.id,
**client_data.programs.model_dump(exclude_unset=True),
)
db.add(db_programs)
@@ -229,8 +231,8 @@ class ClientProviderService:
# Legacy methods for custom endpoints
def create_client_provider_legacy(
self, client_data: ClientProviderCreateDTO
def create_clients_and_providers_legacy(
self, company_id: int, client_data: ClientProviderCreateDTO
) -> ClientProviderResponseDTO:
"""Legacy method for creating client/provider"""
try:
@@ -243,7 +245,8 @@ class ClientProviderService:
# Create address if provided
if client_data.address:
db_address = ClientProviderAddress(
client_id=db_client.client_id,
company_id=company_id,
client_id=db_client.id,
**client_data.address.model_dump(exclude_unset=True),
)
self.db.add(db_address)
@@ -251,14 +254,15 @@ class ClientProviderService:
# Create programs if provided
if client_data.programs:
db_programs = ClientProviderPrograms(
client_id=db_client.client_id,
company_id=company_id,
client_id=db_client.id,
**client_data.programs.model_dump(exclude_unset=True),
)
self.db.add(db_programs)
self.db.commit()
return self._get_client_with_relations(db_client.client_id)
return self._get_client_with_relations(db_client.id)
except IntegrityError as e:
self.db.rollback()
@@ -275,7 +279,7 @@ class ClientProviderService:
status_code=500, detail="Error creating client/provider"
)
def get_client_provider(
def get_clients_and_providers(
self, client_id: str
) -> Optional[ClientProviderResponseDTO]:
"""
@@ -321,7 +325,7 @@ class ClientProviderService:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
search: Texto de búsqueda (nombre, RFC, ID)
client_or_provider: Filtrar por tipo (C=Cliente, P=Proveedor)
client_or_provider: Filtrar por tipo (client=Cliente, provider=Proveedor)
enabled_only: Si True, solo retorna activos
Returns:
@@ -367,7 +371,7 @@ class ClientProviderService:
size=len(client_dtos),
)
def update_client_provider(
def update_clients_and_providers(
self, client_id: str, client_data: ClientProviderUpdateDTO
) -> Optional[ClientProviderResponseDTO]:
"""
@@ -447,7 +451,7 @@ class ClientProviderService:
status_code=500, detail="Error updating client/provider"
)
def delete_client_provider(self, client_id: str) -> bool:
def delete_clients_and_providers(self, client_id: str) -> bool:
"""
Elimina un cliente/proveedor
@@ -479,9 +483,9 @@ class ClientProviderService:
def get_clients_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo clientes (C)"""
"""Obtiene solo clientes (client)"""
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "C"
ClientProvider.client_or_provider == "client"
)
clients = query.offset(skip).limit(limit).all()
return [ClientProviderBasicDTO.model_validate(client) for client in clients]
@@ -489,9 +493,9 @@ class ClientProviderService:
def get_providers_only(
self, skip: int = 0, limit: int = 100
) -> List[ClientProviderBasicDTO]:
"""Obtiene solo proveedores (P)"""
"""Obtiene solo proveedores (provider)"""
query = self.db.query(ClientProvider).filter(
ClientProvider.client_or_provider == "P"
ClientProvider.client_or_provider == "provider"
)
providers = query.offset(skip).limit(limit).all()
return [

View File

@@ -72,7 +72,7 @@ class Pedimentos(Base, TenantScopedMixin, TimestampMixin):
["company_id"], ["a76.company.id"], name="fk_pedimentos_company"
),
ForeignKeyConstraint(
["client_id"], ["a76.client_provider.id"], name="fk_pedimentos_client"
["client_id"], ["a76.clients_and_providers.id"], name="fk_pedimentos_client"
),
ForeignKeyConstraint(
["regime"], ["public.pedimento_regimens.code"], name="fk_pedimentos_regime"

View File

@@ -10,7 +10,7 @@ from .customs_brokers.routes import router as customs_broker_router
# Importar routers de módulos
from .auth import router as auth_router
from .classes import router as classes_router
from .client_and_provider import router as client_and_provider_router
from .clients_and_providers import router as client_and_provider_router
from .company import router as company_router
from .country_rule_oct.routes import router as country_rule_oct_router
from .drivers.routes import router as drivers_router

View File

@@ -4,11 +4,12 @@
1. [Visión General](#visión-general)
2. [Stack Tecnológico](#stack-tecnológico)
3. [Arquitectura del Sistema](#arquitectura-del-sistema)
4. [Estructura del Proyecto](#estructura-del-proyecto)
5. [Flujos Principales](#flujos-principales)
6. [Seguridad](#seguridad)
7. [Base de Datos](#base-de-datos)
8. [API Reference](#api-reference)
4. [Arquitectura de Schemas y Módulos](#arquitectura-de-schemas-y-módulos)
5. [Estructura del Proyecto](#estructura-del-proyecto)
6. [Flujos Principales](#flujos-principales)
7. [Seguridad](#seguridad)
8. [Base de Datos](#base-de-datos)
9. [API Reference](#api-reference)
---
@@ -78,12 +79,12 @@ Anexo76 es una aplicación SaaS multi-tenant para gestión de comercio exterior
┌───────▼──────────────────────────┐
│ DATABASE LAYER (Multi-tenant) │
│ ┌──────────┐ ┌──────────────┐ │
│ │ Core DB │ │ Tenant 1 DB │ │
│ │ (shared) │ │ (dedicated) │ │
│ └──────────┘ └──────────────┘ │
└──────────────────────────────────
│ │
│ ┌──────────┐ ┌──────────────┐
│ │ Core DB │ │ Tenant 1 DB │
│ │ (shared) │ │ (dedicated) │
│ └──────────┘ └──────────────┘
└──────────────────────────────────┘
```
### Estructura Modular (por módulo)
@@ -123,6 +124,104 @@ modules/{module_name}/
---
## Arquitectura de Schemas y Módulos
### Estructura de Schemas en Base de Datos
La aplicación utiliza una arquitectura de schemas para organizar lógicamente las tablas según su funcionalidad y alcance:
#### **Schema `a24` (Anexo 24)**
Contiene todas las tablas relacionadas con el **Anexo 24 del SAT** (control de inventarios para empresas IMMEX):
- Gestión de inventarios
- Control de entradas y salidas de mercancías
- Reportes de existencias
- Cumplimiento de obligaciones fiscales del Anexo 24
#### **Schema `a76` (Anexo 76)**
Contiene todas las tablas relacionadas con el **Anexo 76 del SAT** (comercio exterior):
- Pedimentos aduanales
- Facturas de importación/exportación
- Documentación de comercio exterior
- Cumplimiento normativo de comercio exterior
#### **Schema `public` (Catálogos Fijos)**
Contiene **catálogos compartidos** y datos de referencia que no cambian frecuentemente:
- Catálogos del SAT (tipos de material, unidades de medida, etc.)
- Códigos de país
- Catálogos de aduanas
- Tipos de documento
- Datos maestros compartidos entre módulos
### Convención de Prefijos de Tablas
Para mantener claridad y trazabilidad, las tablas utilizan prefijos que identifican su módulo funcional:
#### **Prefijo `inv_` (Inventarios)**
Tablas relacionadas con el **control de inventarios**:
- `inv_products`: Productos en inventario
- `inv_movements`: Movimientos de entrada/salida
- `inv_warehouses`: Almacenes
- `inv_balances`: Saldos de inventario
**Nota histórica**: Anteriormente se utilizaba el prefijo `s` (SCAII - Sistema de aduanas e Inventarios).
#### **Prefijo `fa_` (Fixed Assets / Activos Fijos)**
Tablas relacionadas con la **gestión de activos fijos**:
- `fa_assets`: Registro de activos fijos
- `fa_depreciation`: Depreciación de activos
- `fa_maintenance`: Mantenimiento de activos
- `fa_transfers`: Transferencias de activos
**Nota histórica**: Anteriormente se utilizaba el prefijo `q` (SCAF - Sistema de Control de Activos Fijos).
### Diagrama de Arquitectura de Schemas
```
┌─────────────────────────────────────────────────────────────┐
│ DATABASE: anexo76_db │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ Schema: a24 │ │ Schema: a76 │ │Schema: public │ │
│ │ (Anexo 24) │ │ (Anexo 76) │ │ (Catálogos) │ │
│ ├────────────────┤ ├────────────────┤ ├───────────────┤ │
│ │ │ │ │ │ │ │
│ │ inv_products │ │ pedimentos │ │ material_types│ │
│ │ inv_movements │ │ facturas │ │ uom_codes │ │
│ │ inv_warehouses │ │ customs_docs │ │ countries │ │
│ │ inv_balances │ │ export_ops │ │ customs_list │ │
│ │ │ │ │ │ document_types│ │
│ │ fa_assets │ │ │ │ │ │
│ │ fa_depreciation│ │ │ │ │ │
│ │ fa_maintenance │ │ │ │ │ │
│ │ fa_transfers │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └────────────────┘ └────────────────┘ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Ventajas de esta Arquitectura
1. **Separación Lógica**: Cada schema representa un dominio específico del negocio
2. **Escalabilidad**: Facilita la adición de nuevos módulos sin afectar los existentes
3. **Seguridad**: Permite aplicar permisos a nivel de schema
4. **Mantenibilidad**: Código y migraciones organizados por dominio
5. **Claridad**: Los prefijos hacen evidente la funcionalidad de cada tabla
6. **Migración Gradual**: Permite actualizar sistemas legados (SCAII/SCAF) sin interrupciones
### Mapeo de Sistemas Legados
| Sistema Legacy | Prefijo Antiguo | Sistema Nuevo | Prefijo Nuevo | Schema |
|----------------------|-----------------|-------------------|---------------|----------|
| SCAII (Inventarios) | `s` | Inventarios | `inv_` | `a24` |
| SCAF (Activos Fijos) | `q` | Fixed Assets | `fa_` | `a24` |
| Winsaii (Pedimentos) | `w` | - | - | `a22` |
| - | `g` | Comercio Exterior | - | `a76` |
| - | `g` | Catálogos SAT | - | `public` |
---
## Estructura del Proyecto
```
@@ -143,11 +242,25 @@ anexo76/
│ └── api/
│ └── v1/
│ ├── router.py # Router principal v1
── modules/ # Módulos de negocio
├── auth/ # Autenticación
├── tenants/ # Gestión de tenants
├── licenses/ # Control de licencias
└── ... # Futuros módulos
── common/ # Utilidades compartidas
├── base_models.py
├── crud_routes.py
├── dto_mixins.py
└── tenant_crud_routes.py
│ │
│ └── modules/ # Módulos de negocio por schema
│ ├── a24/ # Módulo Anexo 24 (Inventarios)
│ │ ├── inventarios/
│ │ └── activos_fijos/
│ │
│ ├── a76/ # Módulo Anexo 76 (Comercio Exterior)
│ │ ├── pedimentos/
│ │ └── facturas/
│ │
│ └── public/ # Catálogos compartidos
│ ├── material_types/
│ ├── uom_codes/
│ └── countries/
├── frontend/
│ ├── src/

View File

@@ -2,6 +2,15 @@
Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
# Script auto initialize
Te genera toda la configruracion inicial de keycloack que se ve en este documento,
aparte de esto te genera un primer usuario configurado con su tenant y una company
```
scripts/init_first_time.sh
```
## 1. Acceder a Keycloak Admin Console
1. Abrir http://localhost:8080
@@ -11,6 +20,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
## 2. Configurar Cliente Backend
### Crear Cliente Backend
1. En el menú izquierdo, ir a **Clients**
2. Clic en **Create client**
3. Configurar:
@@ -29,6 +39,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
- Clic en **Save**
### Obtener Client Secret
1. Ir a la pestaña **Credentials**
2. Copiar el **Client secret**
3. Agregar al archivo `backend/.env`:
@@ -39,6 +50,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
## 3. Configurar Cliente Frontend
### Crear Cliente Frontend
1. En **Clients**, clic en **Create client**
2. Configurar:
- **Client ID**: `anexo76-frontend`
@@ -51,13 +63,13 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
- Clic en **Next**
4. En "Login settings":
- **Root URL**: `http://localhost:5173`
- **Valid redirect URIs**:
- **Valid redirect URIs**:
- `http://localhost:5173/*`
- `http://localhost:3000/*`
- **Valid post logout redirect URIs**:
- **Valid post logout redirect URIs**:
- `http://localhost:5173/*`
- `http://localhost:3000/*`
- **Web origins**:
- **Web origins**:
- `http://localhost:5173`
- `http://localhost:3000`
- Clic en **Save**
@@ -65,6 +77,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
## 4. Crear Usuario de Prueba
### Crear Usuario
1. En el menú izquierdo, ir a **Users**
2. Clic en **Add user**
3. Configurar:
@@ -76,6 +89,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
- Clic en **Create**
### Establecer Contraseña
1. Ir a la pestaña **Credentials**
2. Clic en **Set password**
3. Configurar:
@@ -85,6 +99,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
4. Clic en **Save**
### Agregar Atributo tenant_id
1. En el mismo usuario, ir a la pestaña **Attributes**
2. Clic en **Add an attribute**
3. Configurar:
@@ -93,6 +108,7 @@ Esta guía te ayudará a configurar Keycloak para usar con Anexo76.
4. Clic en **Save**
### Asignar Roles
1. Ir a la pestaña **Role mappings**
2. En "Available roles", buscar y asignar:
- `admin` (si existe)
@@ -126,6 +142,7 @@ Repetir para el cliente `anexo76-frontend` si es necesario.
## 6. Verificar Configuración
### Probar desde el Frontend
1. Abrir http://localhost:5173
2. Hacer clic en "Iniciar Sesión"
3. Ingresar credenciales:
@@ -134,6 +151,7 @@ Repetir para el cliente `anexo76-frontend` si es necesario.
4. Deberías ver el dashboard con información del usuario y licencia
### Probar desde el API
```bash
# Obtener token
curl -X POST http://localhost:8080/realms/master/protocol/openid-connect/token \
@@ -152,11 +170,13 @@ curl -X GET http://localhost:8000/v1/auth/me \
## 7. Configuración Adicional (Opcional)
### Personalizar Tema de Login
1. Ir a **Realm settings** → **Themes**
2. Seleccionar tema de login deseado
3. Guardar cambios
### Configurar Timeout de Sesión
1. Ir a **Realm settings** → **Sessions**
2. Ajustar:
- **SSO Session Idle**: Tiempo de inactividad antes de expirar (ej: 30 minutos)
@@ -164,6 +184,7 @@ curl -X GET http://localhost:8000/v1/auth/me \
3. Guardar cambios
### Habilitar Registro de Usuarios (Opcional)
1. Ir a **Realm settings** → **Login**
2. Activar **User registration**
3. Guardar cambios
@@ -171,20 +192,24 @@ curl -X GET http://localhost:8000/v1/auth/me \
## Troubleshooting
### Error: "Invalid redirect URI"
- Verificar que las URIs en el cliente coincidan exactamente
- Incluir el protocolo (http:// o https://)
- Incluir el puerto si es necesario
### Error: "Client not found"
- Verificar que el Client ID sea exacto
- Verificar que el realm sea correcto
### Token no incluye tenant_id
- Verificar que el usuario tenga el atributo configurado
- Verificar que el mapper esté configurado correctamente
- Probar obteniendo un nuevo token
### Usuario no puede hacer login
- Verificar que el usuario esté habilitado (User enabled: ON)
- Verificar que el email esté verificado (Email verified: ON)
- Verificar que la contraseña no sea temporal

View File

@@ -1,174 +0,0 @@
# Módulos A76 Implementados - Anexo 76
**Fecha de implementación:** 4 de noviembre de 2025
---
## ✨ Nuevas Funcionalidades
### Módulo de Empresa (Company)
- Gestión de empresa única con información comercial completa
- Manejo de datos fiscales y operativos centralizados
### Módulo de Clientes y Proveedores (Client & Provider)
- Gestión integral de clientes y proveedores
- Relaciones con direcciones y programas asociados
- Capacidad de diferenciar entre clientes y proveedores
### Módulo de Partes (GParts)
- Gestión de partes/componentes para los sistemas SCAII, SCAF y WINSAAI
- Control de inventario y clasificación arancelaria
- Información regulatoria y de cumplimiento
### Módulo de Clases (Class)
- Clasificaciones para sistemas SCAII y SCAF
- Información arancelaria detallada
- Gestión de fracciones arancelarias y materiales
---
## 🔗 Relaciones de Base de Datos
### Relaciones Principales
- **Part ↔ Class**: Relación de clave compuesta (client_id, part_class ↔ class_code)
- **Part → Country**: Clave foránea a public.countries (country_of_origin)
- **Part → CurrencyType**: Clave foránea a public.currency_types (currency_key)
- **Class → MaterialType**: Clave foránea a public.material_types (material_key)
### Esquema de Relaciones
```
Part (Partes)
├── País de origen → Country
├── Tipo de moneda → CurrencyType
└── Información de clase → Class
└── Tipo de material → MaterialType
```
---
## 📊 Endpoints de API Agregados
### Módulo Empresa (`/company`)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/` | Crear empresa |
| GET | `/` | Obtener información de la empresa |
### Módulo Clientes y Proveedores (`/clients-providers`)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/` | Crear cliente/proveedor |
| GET | `/` | Listar todos con paginación |
| GET | `/clients` | Listar solo clientes |
| GET | `/providers` | Listar solo proveedores |
| GET | `/search/rfc/{rfc}` | Buscar por RFC |
| GET | `/{client_id}` | Obtener por ID |
| PUT | `/{client_id}` | Actualizar cliente/proveedor |
| DELETE | `/{client_id}` | Eliminar cliente/proveedor |
| PATCH | `/{client_id}/toggle-status` | Cambiar estatus |
| GET | `/{client_id}/address` | Obtener información de dirección |
| GET | `/{client_id}/programs` | Obtener información de programas |
| GET | `/{client_id}/basic` | Obtener información básica |
### Módulo Partes (`/parts`)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/` | Crear parte |
| GET | `/` | Listar todas con paginación y filtros |
| GET | `/client/{client_id}` | Obtener partes por cliente |
| GET | `/search/fraction/{fraction}` | Buscar por fracción arancelaria |
| GET | `/search/supplier/{supplier}` | Buscar por proveedor |
| GET | `/search/country/{country_code}` | Buscar por país |
| GET | `/statistics` | Obtener estadísticas de partes |
| GET | `/{client_id}/{part_number}` | Obtener parte específica |
| PUT | `/{client_id}/{part_number}` | Actualizar parte |
| DELETE | `/{client_id}/{part_number}` | Eliminar parte |
| PATCH | `/{client_id}/{part_number}/toggle-status` | Cambiar estatus |
| GET | `/{client_id}/{part_number}/basic` | Obtener información básica |
| GET | `/{client_id}/{part_number}/regulatory` | Obtener información regulatoria |
### Módulo Clases (`/classes`)
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | `/` | Crear clase |
| GET | `/` | Listar todas con paginación y filtros |
| GET | `/client/{client_id}` | Obtener clases por cliente |
| GET | `/search/fraction/{fraction}` | Buscar por fracción arancelaria |
| GET | `/search/material/{material_key}` | Buscar por material |
| GET | `/search/unit-measure/{unit_of_measure}` | Buscar por unidad de medida |
| GET | `/search/physical-review/{physical_review}` | Buscar por revisión física |
| GET | `/statistics` | Obtener estadísticas de clases |
| GET | `/{client_id}/{class_code}` | Obtener clase específica |
| PUT | `/{client_id}/{class_code}` | Actualizar clase |
| DELETE | `/{client_id}/{class_code}` | Eliminar clase |
| GET | `/{client_id}/{class_code}/basic` | Obtener información básica |
| GET | `/{client_id}/{class_code}/tariff` | Obtener información arancelaria |
---
## 🏗️ Arquitectura Implementada
### Diseño Modular
- **Modelos**: Definición de entidades ORM con SQLAlchemy
- **DTOs**: Objetos de transferencia de datos con validación Pydantic
- **Servicios**: Lógica de negocio y operaciones de base de datos
- **Rutas**: Endpoints REST API con documentación automática
### Características Técnicas
- **Nombres de campos en inglés** para consistencia internacional
- **Claves primarias compuestas** donde es aplicable
- **Operaciones CRUD completas** con endpoints de búsqueda especializados
- **Relaciones SQLAlchemy** con restricciones de clave foránea apropiadas
- **DTOs type-safe** con validación Pydantic
### Patrones de Desarrollo
- Estructura consistente en todos los módulos para facilitar mantenimiento
- Separación clara de responsabilidades (models, DTOs, services, routes)
- Validación de datos en múltiples capas
- Manejo de errores estandarizado
- Documentación automática con FastAPI/OpenAPI
---
## 📝 Documentación
### Archivos de Documentación
- **RELATIONSHIPS.md**: Documentación completa de relaciones de base de datos
- **Type hints detallados** en todos los métodos de servicio
- **Comentarios explicativos** en modelos y funciones complejas
### Estándares de Código
- Consistencia en patrones de desarrollo entre módulos
- Nomenclatura estandarizada para endpoints y funciones
- Validación robusta de datos de entrada y salida
- Manejo de excepciones centralizado
---
## 📈 Resumen de Implementación
### Números Totales
- **4 módulos completos** implementados
- **42+ endpoints** REST API disponibles
- **23 archivos nuevos** agregados al proyecto
- **2,798+ líneas de código** implementadas
### Estado del Proyecto
- ✅ Modelos de base de datos implementados
- ✅ Relaciones entre entidades establecidas
- ✅ DTOs con validación completa
- ✅ Servicios con lógica de negocio
- ✅ Endpoints REST API funcionales
- ✅ Integración en router principal
- ⏳ Migraciones de base de datos (pendiente)
### Próximos Pasos
1. Crear migraciones de Alembic para las nuevas tablas
2. Implementar tests unitarios para cada módulo
3. Agregar documentación de API con ejemplos
4. Implementar autenticación y autorización
5. Optimizar consultas de base de datos
---
*Documento generado automáticamente el 4 de noviembre de 2025*

View File

@@ -1,107 +0,0 @@
# Relaciones entre Modelos A76
## Resumen de Relaciones Establecidas
### Part (Tabla: parts)
El modelo `Part` representa las partes/componentes en los sistemas SCAII, SCAF y WINSAAI.
#### Relaciones:
1. **Con Country (public.countries)**
- Campo: `country_of_origin``countries.m3_key`
- Relación: Many-to-One
- Propósito: País de origen de la parte
2. **Con CurrencyType (public.currency_types)**
- Campo: `currency_key``currency_types.code`
- Relación: Many-to-One
- Propósito: Tipo de moneda para el costo unitario
3. **Con Class (classes)**
- Campos: `(client_id, part_class)``(client_id, class_code)`
- Relación: Many-to-One (usando primaryjoin complejo)
- Propósito: Clasificación de la parte
- Atributo: `part_class_info`
### Class (Tabla: classes)
El modelo `Class` representa las clases de clasificación en sistemas SCAII y SCAF.
#### Relaciones:
1. **Con MaterialType (public.material_types)**
- Campo: `material_key``material_types.key`
- Relación: Many-to-One
- Propósito: Tipo de material de la clase
2. **Con Part (parts)**
- Campos: `(client_id, class_code)``(client_id, part_class)`
- Relación: One-to-Many (inversa de la relación en Part)
- Propósito: Partes que pertenecen a esta clase
- Atributo: `parts`
## Esquema de Relaciones
```
Part
├── country (Country) # País de origen
├── currency (CurrencyType) # Tipo de moneda
└── part_class_info (Class) # Información de clasificación
└── material_type (MaterialType) # Tipo de material
Class
├── material_type (MaterialType) # Tipo de material
└── parts (List[Part]) # Partes que usan esta clase
```
## Uso de las Relaciones
### En consultas:
```python
# Obtener una parte con su información completa
part = session.query(Part).options(
joinedload(Part.country),
joinedload(Part.currency),
joinedload(Part.part_class_info).joinedload(Class.material_type)
).filter(
Part.client_id == 1,
Part.part_number == "PART001"
).first()
# Acceder a los datos relacionados
print(f"País: {part.country.description_es}")
print(f"Moneda: {part.currency.currency_name}")
print(f"Clase: {part.part_class_info.description_spanish}")
print(f"Material: {part.part_class_info.material_type.description}")
```
### En DTOs:
Los DTOs pueden incluir información relacionada:
```python
class PartDetailResponseDTO(BaseModel):
client_id: int
part_number: str
description_spanish: Optional[str]
country_name: Optional[str] = None
currency_name: Optional[str] = None
class_description: Optional[str] = None
material_type: Optional[str] = None
```
## Consideraciones Técnicas
1. **Composite Foreign Keys**: La relación entre `Part` y `Class` usa claves foráneas compuestas que requieren `primaryjoin` personalizado.
2. **Viewonly Relationships**: Algunas relaciones están marcadas como `viewonly=True` para evitar problemas de escritura accidental.
3. **Lazy Loading**: Por defecto, las relaciones usan lazy loading. Para consultas que necesiten datos relacionados, usar `joinedload` o `selectinload`.
4. **Type Hints**: Se usan `TYPE_CHECKING` imports para evitar import circulares mientras se mantienen los type hints.
## Futuras Relaciones
Potenciales relaciones adicionales que se pueden agregar:
1. **Con Sectors** (public.sectors) - para clasificación sectorial
2. **Con Transport Types** (public.transport_types) - para modo de transporte
3. **Con Customs Sections** (public.customs_sections) - para sección aduanera
4. **Relaciones con tablas subsidiarias** como `SPartes`, `QPartes`, etc.

View File

@@ -1,126 +0,0 @@
# Actualización de Schemas A76
**Fecha de actualización:** 5 de noviembre de 2025
---
## ✅ Modelos Actualizados al Schema A76
Se han actualizado todos los modelos en `api/v1/modules/a76/` para usar el schema `a76` en PostgreSQL.
### 📋 Tablas Configuradas
| Módulo | Tabla | Schema | Estado |
|--------|-------|---------|---------|
| **Company** | `company` | `a76` | ✅ Actualizada |
| **Client & Provider** | `client_provider` | `a76` | ✅ Actualizada |
| **Client & Provider** | `client_provider_address` | `a76` | ✅ Actualizada |
| **Client & Provider** | `client_provider_programs` | `a76` | ✅ Actualizada |
| **GParts** | `parts` | `a76` | ✅ Actualizada |
| **Class** | `classes` | `a76` | ✅ Actualizada |
| **Licenses** | `licenses` | `a76` | ✅ Ya estaba |
| **Licenses** | `license_usage` | `a76` | ✅ Ya estaba |
| **Tenants** | `tenants` | `a76` | ✅ Ya estaba |
### 🔄 Cambios Realizados
#### 1. Configuración de Schema
```python
# ANTES
class Company(Base):
__tablename__ = "company"
# DESPUÉS
class Company(Base):
__tablename__ = "company"
__table_args__ = {"schema": "a76"}
```
#### 2. Foreign Keys Actualizadas
```python
# ANTES
client_id = Column(String(8), ForeignKey('client_provider.client_id'), ...)
# DESPUÉS
client_id = Column(String(8), ForeignKey('a76.client_provider.client_id'), ...)
```
### 🏗️ Estructura de Schemas
```
PostgreSQL Database
├── Schema: public
│ ├── countries
│ ├── currency_types
│ ├── material_types
│ └── ... (reference data)
└── Schema: a76
├── tenants
├── licenses
├── license_usage
├── company
├── client_provider
├── client_provider_address
├── client_provider_programs
├── parts
└── classes
```
### 🔗 Relaciones Mantenidas
Las relaciones entre schemas funcionan correctamente:
- **A76 → Public**: Los modelos A76 pueden referenciar datos de referencia en `public`
- **A76 → A76**: Las relaciones internas del schema A76 están actualizadas
- **Composite Keys**: Las relaciones con claves compuestas funcionan correctamente
#### Ejemplos de Relaciones Cross-Schema:
```python
# Part (a76) → Country (public)
country_of_origin = Column(String(3), ForeignKey('public.countries.m3_key'))
# Part (a76) → CurrencyType (public)
currency_key = Column(String(3), ForeignKey('public.currency_types.code'))
# Class (a76) → MaterialType (public)
material_key = Column(String(10), ForeignKey('public.material_types.key'))
```
### 🎯 Beneficios de la Separación
1. **Organización**: Datos de negocio separados de datos de referencia
2. **Seguridad**: Permisos granulares por schema
3. **Mantenimiento**: Facilita respaldos y migraciones selectivas
4. **Escalabilidad**: Permite distribuir schemas en el futuro
5. **Claridad**: Separación lógica de responsabilidades
### ⚠️ Consideraciones Importantes
1. **Migraciones**: Las nuevas migraciones deben especificar el schema `a76`
2. **Permisos DB**: El usuario de base de datos necesita permisos en ambos schemas
3. **Testing**: Los tests deben considerar la estructura de schemas
4. **Backup**: Configurar respaldos para incluir ambos schemas
### 📝 Próximos Pasos
1. **Crear migraciones de Alembic** con el schema correcto
2. **Verificar permisos** de base de datos para el usuario de aplicación
3. **Actualizar tests** para considerar la estructura de schemas
4. **Documentar convenciones** de naming para futuros modelos
---
### 🔧 Comando de Verificación
Para verificar que todos los modelos tienen el schema correcto:
```bash
grep -r "__table_args__ = {\"schema\": \"a76\"}" backend/api/v1/modules/a76/*/models.py
```
**Resultado esperado:** 8 coincidencias (una por cada modelo A76)
---
*Actualización completada el 5 de noviembre de 2025*

View File

@@ -72,6 +72,8 @@
"customs_sections": "Customs Sections",
"anexo_22_app_31": "Anexo 22 App 3"
},
"clients_and_providers": "Clients and Providers",
"customs_brokers": "Customs Brokers",
"nav_user": {
"profile": "Profile",
"settings": "Settings",

View File

@@ -71,6 +71,12 @@
"payment_methods": "Formas de Pago",
"customs_sections": "Secciones Aduaneras",
"anexo_22_app_31": "Anexo 22 App 3"
},
"clients_and_providers": "Clientes y Proveedores",
"customs_brokers": "Agentes Aduanales",
"nav_user": {
"profile": "Perfil",
"settings": "Configuración"
}
}
}

View File

@@ -55,6 +55,7 @@
"vitest-browser-svelte": "^1.1.0"
},
"dependencies": {
"keycloak-js": "^26.2.1"
"keycloak-js": "^26.2.1",
"lucide-svelte": "^0.553.0"
}
}

View File

@@ -11,6 +11,9 @@ importers:
keycloak-js:
specifier: ^26.2.1
version: 26.2.1
lucide-svelte:
specifier: ^0.553.0
version: 0.553.0(svelte@5.40.2)
devDependencies:
'@eslint/compat':
specifier: ^1.4.0
@@ -1414,6 +1417,11 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lucide-svelte@0.553.0:
resolution: {integrity: sha512-pOqzFX+RfcNyvjF0+nGVnSmprd+4NQ6mvpLOLEmhTyZGOad8+OtCl65822E7Rx9qE7rfKw84ODKI2v318JZ/7g==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -3181,6 +3189,10 @@ snapshots:
loupe@3.2.1: {}
lucide-svelte@0.553.0(svelte@5.40.2):
dependencies:
svelte: 5.40.2
lz-string@1.5.0: {}
magic-string@0.30.19:

View File

@@ -0,0 +1,202 @@
/**
* API Client para Clientes y Proveedores
* Gestiona las operaciones CRUD para clientes y proveedores
*/
import { api } from '$lib/api';
export interface ClientProviderAddress {
id?: number;
street?: string | null;
neighborhood?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
zip_code?: string | null;
client_id?: number;
}
export interface ClientProviderPrograms {
id?: number;
program_code?: string | null;
authorization_date?: string | null;
client_id?: number;
}
export interface ClientProvider {
id: number;
rfc: string;
name: string;
curp?: string | null;
residence_country?: string | null;
domicile_fiscal?: string | null;
foreign_tax_id?: string | null;
client_or_provider?: string | null;
enabled_disabled?: number;
tenant_id: number;
address?: ClientProviderAddress | null;
programs?: ClientProviderPrograms | null;
}
export interface ClientProviderBasic {
id: number;
rfc: string;
name: string;
curp?: string | null;
residence_country?: string | null;
domicile_fiscal?: string | null;
foreign_tax_id?: string | null;
client_or_provider?: string | null;
enabled_disabled?: number;
tenant_id: number;
}
export interface ClientProviderListResponse {
items: ClientProvider[];
total: number;
page: number;
page_size: number;
}
export interface CreateClientProviderData {
rfc: string;
name: string;
curp?: string | null;
residence_country?: string | null;
domicile_fiscal?: string | null;
foreign_tax_id?: string | null;
client_or_provider?: string | null;
enabled_disabled?: number;
address?: Omit<ClientProviderAddress, 'id' | 'client_id'> | null;
programs?: Omit<ClientProviderPrograms, 'id' | 'client_id'> | null;
}
export interface UpdateClientProviderData {
rfc?: string;
name?: string;
curp?: string | null;
residence_country?: string | null;
domicile_fiscal?: string | null;
foreign_tax_id?: string | null;
client_or_provider?: string | null;
enabled_disabled?: number;
address?: Partial<ClientProviderAddress> | null;
programs?: Partial<ClientProviderPrograms> | null;
}
/**
* API para Clientes y Proveedores
*/
export const clientsProvidersApi = {
/**
* Lista todos los clientes y proveedores con paginación
* @param companyId - ID de la compañía
* @param page - Número de página (por defecto 1)
* @param pageSize - Tamaño de página (por defecto 50)
* @param filters - Filtros opcionales
*/
list: (companyId: number, page = 1, pageSize = 50, filters?: Record<string, any>) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
page: page.toString(),
page_size: pageSize.toString()
});
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value.toString());
}
});
}
return api.get<ClientProviderListResponse>(
`/v1/a76/clients-providers?${params.toString()}`
);
},
/**
* Lista solo clientes (client_or_provider = 'client')
* @param companyId - ID de la compañía
* @param skip - Número de registros a saltar
* @param limit - Límite de registros
*/
listClients: (companyId: number, skip = 0, limit = 100) =>
api.get<ClientProviderBasic[]>(
`/v1/a76/clients-providers/clients?company_id=${companyId}&skip=${skip}&limit=${limit}`
),
/**
* Lista solo proveedores (client_or_provider = 'provider')
* @param companyId - ID de la compañía
* @param skip - Número de registros a saltar
* @param limit - Límite de registros
*/
listProviders: (companyId: number, skip = 0, limit = 100) =>
api.get<ClientProviderBasic[]>(
`/v1/a76/clients-providers/providers?company_id=${companyId}&skip=${skip}&limit=${limit}`
),
/**
* Busca clientes/proveedores por RFC
* @param companyId - ID de la compañía
* @param rfc - RFC a buscar
*/
searchByRfc: (companyId: number, rfc: string) =>
api.get<ClientProviderBasic[]>(
`/v1/a76/clients-providers/search/rfc/${rfc}?company_id=${companyId}`
),
/**
* Obtiene un cliente/proveedor por ID
* @param id - ID del cliente/proveedor
* @param companyId - ID de la compañía
*/
get: (id: number, companyId: number) =>
api.get<ClientProvider>(`/v1/a76/clients-providers/${id}?company_id=${companyId}`),
/**
* Obtiene información básica de un cliente/proveedor
* @param id - ID del cliente/proveedor
* @param companyId - ID de la compañía
*/
getBasic: (id: number, companyId: number) =>
api.get<ClientProviderBasic>(
`/v1/a76/clients-providers/${id}/basic?company_id=${companyId}`
),
/**
* Crea un nuevo cliente/proveedor
* @param companyId - ID de la compañía
* @param data - Datos del cliente/proveedor a crear
*/
create: (companyId: number, data: CreateClientProviderData) =>
api.post<ClientProvider>(`/v1/a76/clients-providers?company_id=${companyId}`, data),
/**
* Actualiza un cliente/proveedor existente
* @param id - ID del cliente/proveedor a actualizar
* @param companyId - ID de la compañía
* @param data - Datos a actualizar
*/
update: (id: number, companyId: number, data: UpdateClientProviderData) =>
api.put<ClientProvider>(`/v1/a76/clients-providers/${id}?company_id=${companyId}`, data),
/**
* Alterna el estado activo/inactivo de un cliente/proveedor
* @param id - ID del cliente/proveedor
* @param companyId - ID de la compañía
*/
toggleStatus: (id: number, companyId: number) =>
api.put<ClientProvider>(
`/v1/a76/clients-providers/${id}/toggle-status?company_id=${companyId}`,
{}
),
/**
* Elimina un cliente/proveedor
* @param id - ID del cliente/proveedor a eliminar
* @param companyId - ID de la compañía
*/
delete: (id: number, companyId: number) =>
api.delete(`/v1/a76/clients-providers/${id}?company_id=${companyId}`)
};

View File

@@ -0,0 +1,111 @@
import type { ColumnDef } from "@tanstack/table-core";
import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/index.js";
import { createRawSnippet } from "svelte";
import DataTableActions from "./data-table-actions.svelte";
import type { ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
export type { ClientProvider };
export function createColumns(onSuccess?: () => void): ColumnDef<ClientProvider>[] {
return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<span class="font-medium">${id}</span>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{
accessorKey: "rfc",
header: "RFC",
cell: ({ row }) => {
const rfcSnippet = createRawSnippet<[{ rfc: string }]>((getRfc) => {
const { rfc } = getRfc();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${rfc}</code>`
};
});
return renderSnippet(rfcSnippet, { rfc: row.original.rfc });
}
},
{
accessorKey: "name",
header: "Nombre",
cell: ({ row }) => {
const nameSnippet = createRawSnippet<[{ name: string }]>((getName) => {
const { name } = getName();
return {
render: () => `<div class="max-w-[300px] truncate font-medium">${name}</div>`
};
});
return renderSnippet(nameSnippet, { name: row.original.name });
}
},
{
accessorKey: "client_or_provider",
header: "Tipo",
cell: ({ row }) => {
const typeSnippet = createRawSnippet<[{ type: string | null | undefined }]>((getType) => {
const { type } = getType();
const displayType = type === 'client' ? 'Cliente' : type === 'provider' ? 'Proveedor' : type === 'both' ? 'Ambos' : 'N/A';
const colorClass = type === 'client' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
: type === 'provider' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: type === 'both' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
return {
render: () => `<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">${displayType}</span>`
};
});
return renderSnippet(typeSnippet, { type: row.original.client_or_provider });
}
},
{
accessorKey: "residence_country",
header: "País",
cell: ({ row }) => {
const countrySnippet = createRawSnippet<[{ country: string | null | undefined }]>((getCountry) => {
const { country } = getCountry();
return {
render: () => `<div class="max-w-[120px] truncate">${country || '-'}</div>`
};
});
return renderSnippet(countrySnippet, { country: row.original.residence_country });
}
},
{
accessorKey: "enabled_disabled",
header: "Estado",
cell: ({ row }) => {
const statusSnippet = createRawSnippet<[{ status: number | undefined }]>((getStatus) => {
const { status } = getStatus();
const isEnabled = status === 1;
const statusText = isEnabled ? 'Activo' : 'Inactivo';
const colorClass = isEnabled
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
return {
render: () => `<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">${statusText}</span>`
};
});
return renderSnippet(statusSnippet, { status: row.original.enabled_disabled });
}
},
{
id: "actions",
cell: ({ row }) => {
return renderComponent(DataTableActions, { item: row.original, onSuccess });
}
}
];
}
// Mantener compatibilidad hacia atrás
export const columns = createColumns();

View File

@@ -0,0 +1,461 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { clientsProvidersApi, type ClientProvider, type CreateClientProviderData, type UpdateClientProviderData } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte";
let {
open = $bindable(false),
item = $bindable<ClientProvider | null>(null),
onSuccess
}: {
open: boolean;
item?: ClientProvider | null;
onSuccess?: () => void;
} = $props();
let formData = $state({
rfc: "",
name: "",
curp: "",
residence_country: "",
domicile_fiscal: "",
foreign_tax_id: "",
client_or_provider: "client",
enabled_disabled: 1,
// Address fields
street: "",
neighborhood: "",
city: "",
state: "",
country: "",
zip_code: "",
// Programs fields
program_code: "",
authorization_date: ""
});
let loading = $state(false);
let error = $state<string | null>(null);
// Actualizar formData cuando item cambia
$effect(() => {
if (item) {
formData = {
rfc: item.rfc,
name: item.name,
curp: item.curp || "",
residence_country: item.residence_country || "",
domicile_fiscal: item.domicile_fiscal || "",
foreign_tax_id: item.foreign_tax_id || "",
client_or_provider: item.client_or_provider || "client",
enabled_disabled: item.enabled_disabled ?? 1,
street: item.address?.street || "",
neighborhood: item.address?.neighborhood || "",
city: item.address?.city || "",
state: item.address?.state || "",
country: item.address?.country || "",
zip_code: item.address?.zip_code || "",
program_code: item.programs?.program_code || "",
authorization_date: item.programs?.authorization_date || ""
};
} else {
formData = {
rfc: "",
name: "",
curp: "",
residence_country: "",
domicile_fiscal: "",
foreign_tax_id: "",
client_or_provider: "client",
enabled_disabled: 1,
street: "",
neighborhood: "",
city: "",
state: "",
country: "",
zip_code: "",
program_code: "",
authorization_date: ""
};
}
});
const isEditing = $derived(!!item);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!companyStore.activeCompany) {
error = "No hay compañía seleccionada";
return;
}
loading = true;
error = null;
try {
let response;
if (isEditing && item) {
const payload: UpdateClientProviderData = {
rfc: formData.rfc,
name: formData.name,
curp: formData.curp || null,
residence_country: formData.residence_country || null,
domicile_fiscal: formData.domicile_fiscal || null,
foreign_tax_id: formData.foreign_tax_id || null,
client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null,
enabled_disabled: formData.enabled_disabled,
address: {
street: formData.street || null,
neighborhood: formData.neighborhood || null,
city: formData.city || null,
state: formData.state || null,
country: formData.country || null,
zip_code: formData.zip_code || null,
},
programs: {
program_code: formData.program_code || null,
authorization_date: formData.authorization_date || null,
}
};
response = await clientsProvidersApi.update(item.id, companyStore.activeCompany.id, payload);
} else {
const payload: CreateClientProviderData = {
rfc: formData.rfc,
name: formData.name,
curp: formData.curp || null,
residence_country: formData.residence_country || null,
domicile_fiscal: formData.domicile_fiscal || null,
foreign_tax_id: formData.foreign_tax_id || null,
client_or_provider: formData.client_or_provider as "client" | "provider" | "both" | null,
enabled_disabled: formData.enabled_disabled,
address: {
street: formData.street || null,
neighborhood: formData.neighborhood || null,
city: formData.city || null,
state: formData.state || null,
country: formData.country || null,
zip_code: formData.zip_code || null,
},
programs: {
program_code: formData.program_code || null,
authorization_date: formData.authorization_date || null,
}
};
response = await clientsProvidersApi.create(companyStore.activeCompany.id, payload);
}
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
}
return;
}
// Éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al guardar";
console.error("Error saving:", e);
} finally {
loading = false;
}
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
// Limpiar form al cerrar
formData = {
rfc: "",
name: "",
curp: "",
residence_country: "",
domicile_fiscal: "",
foreign_tax_id: "",
client_or_provider: "client",
enabled_disabled: 1,
street: "",
neighborhood: "",
city: "",
state: "",
country: "",
zip_code: "",
program_code: "",
authorization_date: ""
};
error = null;
}
open = newOpen;
}
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{isEditing ? "Editar" : "Nuevo"} Cliente/Proveedor
</Dialog.Title>
<Dialog.Description>
{isEditing
? "Modifica los datos del cliente o proveedor."
: "Completa los datos para crear un nuevo cliente o proveedor."}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Información básica -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Información Básica</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="rfc">RFC *</Label>
<Input
id="rfc"
bind:value={formData.rfc}
placeholder="Ej: XAXX010101000"
maxlength={13}
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="client_or_provider">Tipo *</Label>
<select
id="client_or_provider"
bind:value={formData.client_or_provider}
required
disabled={loading}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="client">Cliente</option>
<option value="provider">Proveedor</option>
<option value="both">Ambos</option>
</select>
</div>
</div>
<div class="space-y-2">
<Label for="name">Nombre / Razón Social *</Label>
<Input
id="name"
bind:value={formData.name}
placeholder="Nombre completo o razón social"
maxlength={256}
required
disabled={loading}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="curp">CURP</Label>
<Input
id="curp"
bind:value={formData.curp}
placeholder="Ej: XAXX010101HDFXXX00"
maxlength={18}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="residence_country">País de Residencia</Label>
<Input
id="residence_country"
bind:value={formData.residence_country}
placeholder="Ej: México"
maxlength={64}
disabled={loading}
/>
</div>
</div>
</div>
<!-- Información fiscal -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Información Fiscal</h3>
<div class="space-y-2">
<Label for="domicile_fiscal">Domicilio Fiscal</Label>
<Input
id="domicile_fiscal"
bind:value={formData.domicile_fiscal}
placeholder="Domicilio fiscal completo"
maxlength={256}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="foreign_tax_id">ID Fiscal Extranjero</Label>
<Input
id="foreign_tax_id"
bind:value={formData.foreign_tax_id}
placeholder="Para contribuyentes extranjeros"
maxlength={64}
disabled={loading}
/>
</div>
</div>
<!-- Dirección -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Dirección</h3>
<div class="space-y-2">
<Label for="street">Calle</Label>
<Input
id="street"
bind:value={formData.street}
placeholder="Calle y número"
maxlength={256}
disabled={loading}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="neighborhood">Colonia</Label>
<Input
id="neighborhood"
bind:value={formData.neighborhood}
placeholder="Colonia o barrio"
maxlength={128}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="zip_code">Código Postal</Label>
<Input
id="zip_code"
bind:value={formData.zip_code}
placeholder="Ej: 12345"
maxlength={10}
disabled={loading}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="city">Ciudad</Label>
<Input
id="city"
bind:value={formData.city}
placeholder="Ciudad"
maxlength={128}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="state">Estado</Label>
<Input
id="state"
bind:value={formData.state}
placeholder="Estado o provincia"
maxlength={128}
disabled={loading}
/>
</div>
</div>
<div class="space-y-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
placeholder="País"
maxlength={64}
disabled={loading}
/>
</div>
</div>
<!-- Programas -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Programas</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="program_code">Código de Programa</Label>
<Input
id="program_code"
bind:value={formData.program_code}
placeholder="Código del programa"
maxlength={32}
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="authorization_date">Fecha de Autorización</Label>
<Input
id="authorization_date"
type="date"
bind:value={formData.authorization_date}
disabled={loading}
/>
</div>
</div>
</div>
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (open = false)}
disabled={loading}
>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
{isEditing ? "Guardar cambios" : "Crear"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import type { ClientProvider } from "./columns.js";
import CreateEditDialog from "./create-edit-dialog.svelte";
import DetailsDialog from "./details-dialog.svelte";
import DeleteDialog from "./delete-dialog.svelte";
import { clientsProvidersApi } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte";
let {
item,
onSuccess
}: {
item: ClientProvider;
onSuccess?: () => void;
} = $props();
let showDetailsDialog = $state(false);
let showEditDialog = $state(false);
let showDeleteDialog = $state(false);
let isToggling = $state(false);
function handleCopyId() {
navigator.clipboard.writeText(item.id.toString());
}
function handleCopyRfc() {
navigator.clipboard.writeText(item.rfc);
}
function handleViewDetails() {
showDetailsDialog = true;
}
function handleEdit() {
showEditDialog = true;
}
function handleDelete() {
showDeleteDialog = true;
}
async function handleToggleStatus() {
if (isToggling || !companyStore.activeCompany) return;
isToggling = true;
try {
const response = await clientsProvidersApi.toggleStatus(item.id, companyStore.activeCompany.id);
if (response.error) {
console.error('Error toggling status:', response.error);
alert(`Error: ${response.error}`);
} else {
// Llamar al callback de éxito para recargar datos
if (onSuccess) {
onSuccess();
}
}
} catch (error) {
console.error('Error toggling status:', error);
alert('Error cambiando el estado');
} finally {
isToggling = false;
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Group>
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={handleCopyId}>
Copiar ID
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyRfc}>
Copiar RFC
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleToggleStatus} disabled={isToggling}>
{isToggling ? 'Cambiando...' : item.enabled_disabled === 1 ? 'Desactivar' : 'Activar'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dialogs -->
<DetailsDialog bind:open={showDetailsDialog} {item} />
<CreateEditDialog bind:open={showEditDialog} bind:item {onSuccess} />
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />

View File

@@ -0,0 +1,123 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
};
let {
data,
columns,
loading,
hasMore,
loadMore
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
<Table.Root>
<Table.Header class="sticky top-0 bg-background z-10">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && "selected"}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { clientsProvidersApi, type ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
import { companyStore } from "$lib/stores/company.svelte";
let {
open = $bindable(false),
item,
onSuccess
}: {
open: boolean;
item: ClientProvider | null;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
async function handleDelete() {
if (!item || !companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await clientsProvidersApi.delete(item.id, companyStore.activeCompany.id);
if (response.error) {
error = response.error;
return;
}
// Éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al eliminar";
console.error("Error deleting:", e);
} finally {
loading = false;
}
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
error = null;
}
open = newOpen;
}
</script>
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description class="space-y-2">
<p>Esta acción no se puede deshacer. Se eliminará permanentemente este cliente/proveedor:</p>
{#if item}
<div class="mt-2 rounded-lg bg-muted p-3 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">ID:</span>
<span class="font-semibold">{item.id}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">RFC:</span>
<code class="font-mono font-semibold">{item.rfc}</code>
</div>
<div class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre:</span>
<span class="text-xs">{item.name}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Tipo:</span>
<span class="text-xs">
{item.client_or_provider === 'client' ? 'Cliente' :
item.client_or_provider === 'provider' ? 'Proveedor' :
item.client_or_provider === 'both' ? 'Ambos' : 'N/A'}
</span>
</div>
</div>
{/if}
{#if error}
<div class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={loading}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDelete}
disabled={loading}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if loading}
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
Eliminar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Separator } from "$lib/components/ui/separator";
import type { ClientProvider } from "$lib/api/dashboard/a76/clients-providers";
let {
open = $bindable(false),
item
}: {
open: boolean;
item: ClientProvider | null;
} = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
}
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Detalles del Cliente/Proveedor</Dialog.Title>
<Dialog.Description>
Información completa del cliente o proveedor
</Dialog.Description>
</Dialog.Header>
{#if item}
<div class="space-y-4 py-4">
<!-- Información básica -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Información Básica</h3>
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">ID</span>
<span class="text-sm font-medium">{item.id}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">RFC</span>
<code class="text-sm font-mono font-semibold">{item.rfc}</code>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Nombre</span>
<span class="text-sm">{item.name}</span>
</div>
{#if item.curp}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">CURP</span>
<code class="text-sm font-mono">{item.curp}</code>
</div>
{/if}
<Separator />
</div>
<!-- Tipo y Estado -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Clasificación</h3>
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Tipo</span>
{#if item.client_or_provider === 'client'}
<span class="inline-flex w-fit items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
Cliente
</span>
{:else if item.client_or_provider === 'provider'}
<span class="inline-flex w-fit items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
Proveedor
</span>
{:else if item.client_or_provider === 'both'}
<span class="inline-flex w-fit items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-300">
Ambos
</span>
{:else}
<span class="text-sm text-muted-foreground">No especificado</span>
{/if}
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Estado</span>
{#if item.enabled_disabled === 1}
<span class="inline-flex w-fit items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
Activo
</span>
{:else}
<span class="inline-flex w-fit items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-300">
Inactivo
</span>
{/if}
</div>
</div>
<Separator />
</div>
<!-- Información fiscal -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Información Fiscal</h3>
{#if item.residence_country}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">País de Residencia</span>
<span class="text-sm">{item.residence_country}</span>
</div>
{/if}
{#if item.domicile_fiscal}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Domicilio Fiscal</span>
<span class="text-sm">{item.domicile_fiscal}</span>
</div>
{/if}
{#if item.foreign_tax_id}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">ID Fiscal Extranjero</span>
<code class="text-sm font-mono">{item.foreign_tax_id}</code>
</div>
{/if}
<Separator />
</div>
<!-- Dirección (si existe) -->
{#if item.address}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Dirección</h3>
<div class="space-y-1">
{#if item.address.street}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Calle</span>
<span class="text-sm">{item.address.street}</span>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
{#if item.address.neighborhood}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Colonia</span>
<span class="text-sm">{item.address.neighborhood}</span>
</div>
{/if}
{#if item.address.zip_code}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Código Postal</span>
<span class="text-sm">{item.address.zip_code}</span>
</div>
{/if}
</div>
<div class="grid grid-cols-2 gap-2">
{#if item.address.city}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Ciudad</span>
<span class="text-sm">{item.address.city}</span>
</div>
{/if}
{#if item.address.state}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Estado</span>
<span class="text-sm">{item.address.state}</span>
</div>
{/if}
</div>
{#if item.address.country}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">País</span>
<span class="text-sm">{item.address.country}</span>
</div>
{/if}
</div>
<Separator />
</div>
{/if}
<!-- Programas (si existe) -->
{#if item.programs}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Programas</h3>
{#if item.programs.program_code}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Código de Programa</span>
<code class="text-sm font-mono">{item.programs.program_code}</code>
</div>
{/if}
{#if item.programs.authorization_date}
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-muted-foreground">Fecha de Autorización</span>
<span class="text-sm">{new Date(item.programs.authorization_date).toLocaleDateString()}</span>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>
Cerrar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,13 +1,15 @@
import AudioWaveformIcon from "@lucide/svelte/icons/audio-waveform";
import BookOpenIcon from "@lucide/svelte/icons/book-open";
import BotIcon from "@lucide/svelte/icons/bot";
import ChartPieIcon from "@lucide/svelte/icons/chart-pie";
import CommandIcon from "@lucide/svelte/icons/command";
import FrameIcon from "@lucide/svelte/icons/frame";
import GalleryVerticalEndIcon from "@lucide/svelte/icons/gallery-vertical-end";
import MapIcon from "@lucide/svelte/icons/map";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import SquareTerminalIcon from "@lucide/svelte/icons/square-terminal";
import {
BadgeCheck,
ChartPie,
Database,
FileText,
Frame,
GalleryVerticalEnd,
LayoutDashboard,
Package,
Settings2,
Users,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
export interface NavItem {
@@ -59,15 +61,21 @@ export function getSidebarData(): SidebarData {
teams: [
{
name: "Anexo76",
logo: GalleryVerticalEndIcon,
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
],
navMain: [
{
title: "Dashboard",
url: "/dashboard",
icon: LayoutDashboard,
items: [],
},
{
title: m["sidebar.reference_data.title"](),
url: "/dashboard",
icon: SquareTerminalIcon,
icon: Database,
items: [
{
title: m["sidebar.reference_data.codes_pedimento_regimen"](),
@@ -143,7 +151,7 @@ export function getSidebarData(): SidebarData {
{
title: m["sidebar.general_catalogs.title"](),
url: "#",
icon: BotIcon,
icon: Package,
items: [
{
title: m["sidebar.general_catalogs.company_information"](),
@@ -282,7 +290,7 @@ export function getSidebarData(): SidebarData {
{
title: m["sidebar.pedimentos.title"](),
url: "#",
icon: BookOpenIcon,
icon: FileText,
items: [
{
title: m["sidebar.pedimentos.pedimento_management"](),
@@ -310,10 +318,22 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.clients_and_providers"](),
url: "/dashboard/clients_and_providers",
icon: Users,
items: [],
},
{
title: m["sidebar.customs_brokers"](),
url: "/dashboard/customs_brokers",
icon: BadgeCheck,
items: [],
},
{
title: m["sidebar.reference_data.configuracion"](),
url: "#",
icon: Settings2Icon,
icon: Settings2,
items: [
{
title: m["sidebar.reference_data.general"](),
@@ -334,12 +354,12 @@ export function getSidebarData(): SidebarData {
{
name: m["sidebar.reference_data.usuarios"](),
url: "#",
icon: ChartPieIcon,
icon: ChartPie,
},
{
name: m["sidebar.reference_data.ayuda"](),
url: "#",
icon: FrameIcon,
icon: Frame,
},
],
};

View File

@@ -22,43 +22,60 @@
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Platform</Sidebar.GroupLabel>
<Sidebar.GroupLabel>Anexo-76</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.title)}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{#if item.items && item.items.length > 0}
<!-- Dropdown para items con subitems -->
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<!-- Botón simple para items sin subitems -->
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -0,0 +1,92 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
// Esperar a que el layout padre valide/refresque el token
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
items: [],
total: 0,
page: 1,
page_size: 50,
companies: parentData.companies || []
};
}
try {
// Obtener parámetros de paginación de la URL
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
// Obtener company_id de la URL o de las companies del usuario
const companyIdParam = url.searchParams.get('company_id');
const companyId = companyIdParam
? parseInt(companyIdParam)
: parentData.companies?.[0]?.id; // Usar la primera compañía por defecto
if (!companyId) {
return {
error: 'No company selected',
items: [],
total: 0,
page: page,
page_size: pageSize,
companies: parentData.companies || []
};
}
// Usar authenticatedFetch para manejar automáticamente el refresh de tokens
const response = await authenticatedFetch(
`v1/a76/clients-providers?company_id=${companyId}&page=${page}&page_size=${pageSize}`,
{},
cookies,
fetch
);
if (!response.ok) {
const errorText = await response.text();
console.error('📊 [Clients&Providers] API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
return {
error: `Error ${response.status}: ${response.statusText}`,
items: [],
total: 0,
page: page,
page_size: pageSize,
companies: parentData.companies || [],
currentCompanyId: companyId
};
}
const data = await response.json();
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
page_size: data.page_size || pageSize,
error: null,
companies: parentData.companies || [],
currentCompanyId: companyId
};
} catch (error) {
console.error('📊 [Clients&Providers] Load error:', error);
return {
error: 'Error loading data',
items: [],
total: 0,
page: 1,
page_size: 50,
companies: parentData.companies || []
};
}
};

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
import DataTable from '$lib/components/dashboard/clients_and_providers/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/clients_and_providers/columns.js';
import CreateEditDialog from '$lib/components/dashboard/clients_and_providers/create-edit-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Estado para el diálogo de crear
let showCreateDialog = $state(false);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
}
});
// Estado para infinite scroll
let allItems = $state<ClientProvider[]>(data.items || []);
let currentPage = $state(data.page || 1);
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await clientsProvidersApi.list(
companyStore.activeCompany.id,
currentPage + 1,
pageSize
);
if (response.error) {
console.error('📊 [Page] Error en loadMore:', response.error, 'Status:', response.status);
// Si es un error de autenticación (401 o 403) y no se pudo refrescar, mostrar mensaje específico
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
// Recargar automáticamente después de 2 segundos
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
// Agregar los nuevos items al array existente
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📊 [Page] Error loading more:', e);
} finally {
loading = false;
}
}
function reloadData() {
// Reset y recargar desde el principio
window.location.reload();
}
function handleCreateClick() {
showCreateDialog = true;
}
function handleSuccess() {
// Recargar datos después de crear/editar/eliminar
reloadData();
}
// Crear columnas con el callback onSuccess
const columns = createColumns(handleSuccess);
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Clientes y Proveedores</h1>
<p class="text-muted-foreground">
Gestiona el catálogo de clientes y proveedores de tu empresa
</p>
</div>
<Button onclick={handleCreateClick}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
Nuevo Cliente/Proveedor
</Button>
</div>
<!-- Error Message -->
{#if error}
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
</Card.Header>
</Card.Root>
{/if}
<!-- Data Table -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Listado de Clientes y Proveedores</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
{#if companyStore.activeCompany}
- Compañía: {companyStore.activeCompany.name}
{/if}
</Card.Description>
</div>
<Button variant="outline" onclick={reloadData}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
Actualizar
</Button>
</div>
</Card.Header>
<Card.Content>
<!-- TanStack DataTable con Infinite Scroll -->
<DataTable
data={allItems}
{columns}
{loading}
{hasMore}
{loadMore}
/>
</Card.Content>
</Card.Root>
</div>
<!-- Diálogo de crear/editar -->
<CreateEditDialog bind:open={showCreateDialog} onSuccess={handleSuccess} />

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"lucide-svelte": "^0.552.0"
}
}