feat: Convertir módulo de clases para usar TenantCRUDRoutes

Cambios en backend:
- Actualizado ClassResponseDTO con campos de tenant y timestamps
- Agregados métodos estáticos en servicio (get_all, get_by_id, create, update, delete)
- Reemplazados endpoints de rutas con inicialización de TenantCRUDRoutes
- Corregida ruta de importación del modelo Company en security.py
- Actualizados nombres de campos en DTO para coincidir con modelo (description_es/en)
- Agregada validación para constraint de foreign key de material_key
- Agregada validación de class_code duplicado en método create
- Agregado TimestampMixin al modelo Class
- Creada migración de Alembic para agregar columnas timestamp a tabla classes
- Corregido orden de parámetros en firma del método update

Cambios en frontend:
- Actualizados componentes de UI para módulo de clases
This commit is contained in:
KevinMrkz3221
2025-11-15 20:05:29 -06:00
parent 836083b428
commit 99aff32eea
15 changed files with 1703 additions and 295 deletions

View File

@@ -0,0 +1,67 @@
"""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

@@ -3,9 +3,10 @@ DTOs (Data Transfer Objects) para módulo de clases SCAII y SCAF
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
""" """
from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
class ClassCreateDTO(BaseModel): class ClassCreateDTO(BaseModel):
@@ -13,10 +14,10 @@ class ClassCreateDTO(BaseModel):
client_id: int = Field(..., description="Client key") client_id: int = Field(..., description="Client key")
class_code: str = Field(..., max_length=8, description="Class code") class_code: str = Field(..., max_length=8, description="Class code")
description_spanish: Optional[str] = Field( description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish" None, max_length=500, description="Description in Spanish"
) )
description_english: Optional[str] = Field( description_en: Optional[str] = Field(
None, max_length=500, description="Description in English" None, max_length=500, description="Description in English"
) )
material_key: Optional[str] = Field( material_key: Optional[str] = Field(
@@ -50,10 +51,10 @@ class ClassCreateDTO(BaseModel):
class ClassUpdateDTO(BaseModel): class ClassUpdateDTO(BaseModel):
"""DTO para actualizar una clase""" """DTO para actualizar una clase"""
description_spanish: Optional[str] = Field( description_es: Optional[str] = Field(
None, max_length=500, description="Description in Spanish" None, max_length=500, description="Description in Spanish"
) )
description_english: Optional[str] = Field( description_en: Optional[str] = Field(
None, max_length=500, description="Description in English" None, max_length=500, description="Description in English"
) )
material_key: Optional[str] = Field( material_key: Optional[str] = Field(
@@ -87,10 +88,13 @@ class ClassUpdateDTO(BaseModel):
class ClassResponseDTO(BaseModel): class ClassResponseDTO(BaseModel):
"""DTO para respuesta de clase""" """DTO para respuesta de clase"""
id: int
tenant_id: int
company_id: int
client_id: int client_id: int
class_code: str class_code: str
description_spanish: Optional[str] = None description_es: Optional[str] = None
description_english: Optional[str] = None description_en: Optional[str] = None
material_key: Optional[str] = None material_key: Optional[str] = None
unit_of_measure: Optional[str] = None unit_of_measure: Optional[str] = None
fraction: Optional[str] = None fraction: Optional[str] = None
@@ -98,9 +102,10 @@ class ClassResponseDTO(BaseModel):
sub_key: Optional[str] = None sub_key: Optional[str] = None
physical_review: Optional[int] = None physical_review: Optional[int] = None
iva_exempt_fraction: Optional[str] = None iva_exempt_fraction: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config: model_config = ConfigDict(from_attributes=True)
from_attributes = True
class ClassBasicDTO(BaseModel): class ClassBasicDTO(BaseModel):
@@ -108,8 +113,8 @@ class ClassBasicDTO(BaseModel):
client_id: int client_id: int
class_code: str class_code: str
description_spanish: Optional[str] = None description_es: Optional[str] = None
description_english: Optional[str] = None description_en: Optional[str] = None
material_key: Optional[str] = None material_key: Optional[str] = None
fraction: Optional[str] = None fraction: Optional[str] = None

View File

@@ -4,7 +4,7 @@ Modelos ORM para gestión de clases SCAII y SCAF
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base from core.database import Base
from sqlalchemy import ( from sqlalchemy import (
ForeignKey, ForeignKey,
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.material_types.models import MaterialType
class Class(Base, TenantScopedMixin): class Class(Base, TenantScopedMixin, TimestampMixin):
""" """
Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF Modelo para la tabla GClases - Información de clases en sistemas SCAII y SCAF
""" """

View File

@@ -2,285 +2,23 @@
Endpoints API para gestión de clases SCAII y SCAF Endpoints API para gestión de clases SCAII y SCAF
""" """
from typing import List, Optional from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db from .dto import ClassCreateDTO, ClassResponseDTO, ClassUpdateDTO
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from .dto import (
ClassBasicDTO,
ClassCreateDTO,
ClassListDTO,
ClassResponseDTO,
ClassSearchDTO,
ClassUpdateDTO,
)
from .service import ClassService from .service import ClassService
router = APIRouter(prefix="/classes") # Create router with generic CRUD routes
router = TenantCRUDRoutes(
service=ClassService,
@router.get("/", response_model=ClassListDTO) create_schema=ClassCreateDTO,
async def list_classes( update_schema=ClassUpdateDTO,
skip: int = Query(0, ge=0, description="Number of records to skip"), response_schema=ClassResponseDTO,
limit: int = Query( prefix="/classes",
100, ge=1, le=1000, description="Maximum number of records to return" tags=["a76 / classes"],
), resource_name="Class",
client_id: Optional[int] = Query(None, description="Filter by client key"), id_name="class_id",
class_code: Optional[str] = Query(None, description="Search by class code"), enable_list=True,
description: Optional[str] = Query(None, description="Search in descriptions"), enable_filters=True,
material_key: Optional[str] = Query(None, description="Filter by material key"), default_page_size=50,
fraction: Optional[str] = Query(None, description="Filter by tariff fraction"), max_page_size=100,
physical_review: Optional[int] = Query( ).router
None, description="Filter by physical review indicator"
),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
List classes with optional filters and pagination
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = ClassService(db)
search_params = ClassSearchDTO(
client_id=client_id,
class_code=class_code,
description=description,
material_key=material_key,
fraction=fraction,
physical_review=physical_review,
)
return service.list_classes(skip, limit, search_params)
@router.get("/client/{client_id}", response_model=List[ClassBasicDTO])
async def get_classes_by_client(
client_id: int,
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 all classes for a specific client
"""
# Validate access to the tenant and company
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id")
if not tenant_id or not company_id:
raise HTTPException(
status_code=403, detail="Access denied: Tenant or Company not found"
)
service = ClassService(db)
return service.search_by_client(client_id, skip, limit)
@router.get("/search/fraction/{fraction}", response_model=List[ClassBasicDTO])
async def search_by_fraction(
fraction: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search classes by tariff fraction
"""
service = ClassService(db)
return service.search_by_fraction(fraction)
@router.get("/search/material/{material_key}", response_model=List[ClassBasicDTO])
async def search_by_material(
material_key: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Search classes by material key
"""
service = ClassService(db)
return service.search_by_material(material_key)
@router.get(
"/search/unit-measure/{unit_of_measure}", response_model=List[ClassBasicDTO]
)
async def get_classes_by_unit_measure(
unit_of_measure: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get classes by unit of measure
"""
service = ClassService(db)
return service.get_classes_by_unit_measure(unit_of_measure)
@router.get(
"/search/physical-review/{physical_review}", response_model=List[ClassBasicDTO]
)
async def get_classes_by_physical_review(
physical_review: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get classes by physical review indicator
"""
service = ClassService(db)
return service.get_classes_by_physical_review(physical_review)
@router.get("/statistics", response_model=dict)
async def get_classes_statistics(
db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user)
):
"""
Get basic classes statistics
"""
service = ClassService(db)
return service.get_classes_statistics()
@router.get("/{client_id}/{class_code}", response_model=ClassResponseDTO)
async def get_class(
client_id: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get class by composite key (client_id + class_code)
"""
service = ClassService(db)
class_obj = service.get_class(client_id, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
)
return class_obj
@router.post("/", response_model=ClassResponseDTO, status_code=status.HTTP_201_CREATED)
async def create_class(
class_data: ClassCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new class in the system
"""
service = ClassService(db)
return service.create_class(class_data)
@router.put("/{client_id}/{class_code}", response_model=ClassResponseDTO)
async def update_class(
client_id: int,
class_code: str,
class_data: ClassUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Update class information
"""
service = ClassService(db)
class_obj = service.update_class(client_id, class_code, class_data)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
)
return class_obj
@router.delete("/{client_id}/{class_code}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_class(
client_id: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete class from the system
Note: This will completely remove the class from the system.
"""
service = ClassService(db)
if not service.delete_class(client_id, class_code):
raise HTTPException(
status_code=404,
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
)
# Endpoints específicos para información detallada
@router.get("/{client_id}/{class_code}/basic", response_model=ClassBasicDTO)
async def get_class_basic_info(
client_id: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get basic information for a class
"""
service = ClassService(db)
class_obj = service.get_class(client_id, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
)
return ClassBasicDTO(
client_id=class_obj.client_id,
class_code=class_obj.class_code,
description_spanish=class_obj.description_spanish,
description_english=class_obj.description_english,
material_key=class_obj.material_key,
fraction=class_obj.fraction,
)
@router.get("/{client_id}/{class_code}/tariff", response_model=dict)
async def get_class_tariff_info(
client_id: int,
class_code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Get tariff information for a class (fractions, IVA exempt, etc.)
"""
service = ClassService(db)
class_obj = service.get_class(client_id, class_code)
if not class_obj:
raise HTTPException(
status_code=404,
detail=f"Class with client_id '{client_id}' and class_code '{class_code}' not found",
)
return {
"client_id": class_obj.client_id,
"class_code": class_obj.class_code,
"fraction": class_obj.fraction,
"us_fraction": class_obj.us_fraction,
"iva_exempt_fraction": class_obj.iva_exempt_fraction,
"sub_key": class_obj.sub_key,
"physical_review": class_obj.physical_review,
}

View File

@@ -3,7 +3,7 @@ Capa de servicio para lógica de negocio de clases SCAII y SCAF
""" """
import logging import logging
from typing import List, Optional from typing import Any, Dict, List, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
@@ -26,6 +26,160 @@ logger = logging.getLogger(__name__)
class ClassService: class ClassService:
"""Servicio para gestión de clases SCAII y SCAF""" """Servicio para gestión de clases SCAII y SCAF"""
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Class], int]:
"""
Get all classes for a tenant with pagination and filters
"""
query = db.query(Class).filter(
Class.tenant_id == tenant_id, Class.company_id == company_id
)
if filters:
if filters.get("client_id"):
query = query.filter(Class.client_id == filters["client_id"])
if filters.get("class_code"):
query = query.filter(
Class.class_code.ilike(f"%{filters['class_code']}%")
)
if filters.get("description"):
description_pattern = f"%{filters['description']}%"
query = query.filter(
or_(
Class.description_es.ilike(description_pattern),
Class.description_en.ilike(description_pattern),
)
)
if filters.get("material_key"):
query = query.filter(
Class.material_key.ilike(f"%{filters['material_key']}%")
)
if filters.get("fraction"):
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
if filters.get("physical_review") is not None:
query = query.filter(
Class.physical_review == filters["physical_review"]
)
total = query.count()
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session, class_id: int, tenant_id: int, company_id: int
) -> Optional[Class]:
"""Get a class by ID"""
return (
db.query(Class)
.filter(
Class.id == class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session, class_data: ClassCreateDTO, tenant_id: int, company_id: int
) -> Class:
"""Create a new class"""
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
data_dict = class_data.model_dump()
# Check if class_code already exists for this tenant and company
existing = db.query(Class).filter(
Class.tenant_id == tenant_id,
Class.company_id == company_id,
Class.class_code == data_dict["class_code"]
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"Class with code '{data_dict['class_code']}' already exists for this tenant and company"
)
# Validate material_key exists if provided
if data_dict.get("material_key"):
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == data_dict["material_key"]
).first()
if not material_exists:
# Set to None if material_key doesn't exist
data_dict["material_key"] = None
class_obj = Class(**data_dict)
class_obj.tenant_id = tenant_id
class_obj.company_id = company_id
try:
db.add(class_obj)
db.commit()
db.refresh(class_obj)
return class_obj
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=400,
detail=f"Failed to create class: {str(e.orig)}"
)
@staticmethod
def update(
db: Session,
class_id: int,
tenant_id: int,
class_data: ClassUpdateDTO,
company_id: int,
) -> Optional[Class]:
"""Update a class"""
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return None
update_data = class_data.model_dump(exclude_unset=True)
# Validate material_key exists if provided
if "material_key" in update_data and update_data["material_key"]:
from api.v1.modules.public.reference_data.material_types.models import MaterialType
material_exists = db.query(MaterialType).filter(
MaterialType.key == update_data["material_key"]
).first()
if not material_exists:
# Set to None if material_key doesn't exist
update_data["material_key"] = None
for field, value in update_data.items():
setattr(class_obj, field, value)
db.commit()
db.refresh(class_obj)
return class_obj
@staticmethod
def delete(db: Session, class_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete a class"""
class_obj = ClassService.get_by_id(db, class_id, tenant_id, company_id)
if not class_obj:
return False
db.delete(class_obj)
db.commit()
return True
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db

View File

@@ -162,7 +162,7 @@ def validate_company_access(
# Consultar si la compañía pertenece al tenant # Consultar si la compañía pertenece al tenant
try: try:
from api.v1.modules.a76.company.models import Company from api.v1.modules.a76.general_catalogs.company.models import Company
company = ( company = (
db.query(Company) db.query(Company)

View File

@@ -0,0 +1,107 @@
/**
* API para gestión de Classes (Clases A76)
*/
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface A76Class {
id: number;
tenant_id: number;
company_id: number;
client_id: number;
class_code: string;
description_es: string | null;
description_en: string | null;
material_key: string | null;
unit_of_measure: string;
fraction: string;
us_fraction: string;
sub_key: string;
physical_review: number;
iva_exempt_fraction: string;
created_at: string;
updated_at: string;
}
export interface A76ClassCreate {
company_id: number;
client_id: number;
class_code: string;
description_es?: string | null;
description_en?: string | null;
material_key?: string | null;
unit_of_measure: string;
fraction: string;
us_fraction: string;
sub_key: string;
physical_review?: number;
iva_exempt_fraction: string;
}
export interface A76ClassUpdate {
client_id?: number;
class_code?: string;
description_es?: string | null;
description_en?: string | null;
material_key?: string | null;
unit_of_measure?: string;
fraction?: string;
us_fraction?: string;
sub_key?: string;
physical_review?: number;
iva_exempt_fraction?: string;
}
export interface A76ClassListResponse {
items: A76Class[];
total: number;
page: number;
page_size: number;
}
export interface A76ClassListParams {
company_id: number;
page?: number;
page_size?: number;
}
/**
* API de Classes
*/
export const classesApi = {
/**
* Obtener lista de classes con paginación
*/
list: (params: A76ClassListParams): Promise<ApiResponse<A76ClassListResponse>> => {
const { company_id, page = 1, page_size = 50 } = params;
return api.get(`/v1/a76/classes/?company_id=${company_id}&page=${page}&page_size=${page_size}`);
},
/**
* Obtener un class por ID
*/
get: (id: number, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.get(`/v1/a76/classes/${id}?company_id=${company_id}`);
},
/**
* Crear un nuevo class
*/
create: (data: A76ClassCreate, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.post(`/v1/a76/classes/?company_id=${company_id}`, data);
},
/**
* Actualizar un class existente
*/
update: (id: number, data: A76ClassUpdate, company_id: number): Promise<ApiResponse<A76Class>> => {
return api.put(`/v1/a76/classes/${id}?company_id=${company_id}`, data);
},
/**
* Eliminar un class
*/
delete: (id: number, company_id: number): Promise<ApiResponse<void>> => {
return api.delete(`/v1/a76/classes/${id}?company_id=${company_id}`);
}
};

View File

@@ -0,0 +1,4 @@
/**
* Exportaciones de APIs para módulo A76
*/
export * from './classes';

View File

@@ -0,0 +1,177 @@
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 { A76Class } from "$lib/api/dashboard/a76/classes";
/**
* Formatea una fecha
*/
function formatDate(date?: string | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Obtiene el color del badge según el tipo de revisión física
*/
function getReviewColor(physicalReview: number): string {
return physicalReview === 1
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800';
}
export function createColumns(onSuccess?: () => void): ColumnDef<A76Class>[] {
return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<div class="font-medium">#${id}</div>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{
accessorKey: "class_code",
header: "Código de Clase",
cell: ({ row }) => {
const codeSnippet = createRawSnippet<[{ code: string }]>((getCode) => {
const { code } = getCode();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${code}</code>`
};
});
return renderSnippet(codeSnippet, { code: row.original.class_code });
}
},
{
accessorKey: "description_es",
header: "Descripción",
cell: ({ row }) => {
const description = row.original.description_es || row.original.description_en || '-';
const descSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => {
const { desc } = getDesc();
return {
render: () =>
`<div class="max-w-xs truncate" title="${desc}">${desc}</div>`
};
});
return renderSnippet(descSnippet, { desc: description });
}
},
{
accessorKey: "fraction",
header: "Fracción",
cell: ({ row }) => {
const fractionSnippet = createRawSnippet<[{ fraction: string }]>((getFraction) => {
const { fraction } = getFraction();
return {
render: () =>
`<div class="font-mono text-sm">${fraction}</div>`
};
});
return renderSnippet(fractionSnippet, { fraction: row.original.fraction });
}
},
{
accessorKey: "us_fraction",
header: "Fracción US",
cell: ({ row }) => {
const usFractionSnippet = createRawSnippet<[{ usFraction: string }]>((getUsFraction) => {
const { usFraction } = getUsFraction();
return {
render: () =>
`<div class="font-mono text-sm">${usFraction}</div>`
};
});
return renderSnippet(usFractionSnippet, { usFraction: row.original.us_fraction });
}
},
{
accessorKey: "unit_of_measure",
header: "Unidad",
cell: ({ row }) => {
const unitSnippet = createRawSnippet<[{ unit: string }]>((getUnit) => {
const { unit } = getUnit();
return {
render: () =>
`<span class="inline-flex items-center rounded-full bg-blue-100 text-blue-800 px-2.5 py-0.5 text-xs font-medium">
${unit}
</span>`
};
});
return renderSnippet(unitSnippet, { unit: row.original.unit_of_measure });
}
},
{
accessorKey: "physical_review",
header: "Rev. Física",
cell: ({ row }) => {
const review = row.original.physical_review;
const colorClass = getReviewColor(review);
const label = review === 1 ? 'Sí' : 'No';
const reviewSnippet = createRawSnippet<[{ label: string; colorClass: string }]>((getReview) => {
const { label, colorClass } = getReview();
return {
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${label}
</span>`
};
});
return renderSnippet(reviewSnippet, { label, colorClass });
}
},
{
accessorKey: "client_id",
header: "Cliente",
cell: ({ row }) => {
const clientSnippet = createRawSnippet<[{ clientId: number }]>((getClient) => {
const { clientId } = getClient();
return {
render: () =>
`<div class="text-sm">Cliente #${clientId}</div>`
};
});
return renderSnippet(clientSnippet, { clientId: row.original.client_id });
}
},
{
accessorKey: "created_at",
header: "Fecha de Creación",
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
const { date } = getDate();
return {
render: () =>
`<div class="text-sm text-muted-foreground">${date}</div>`
};
});
return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) });
}
},
{
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,464 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import * as Select from "$lib/components/ui/select";
import { classesApi, type A76Class, type A76ClassCreate, type A76ClassUpdate } from "$lib/api/dashboard/a76/classes";
import { materialTypesApi, type MaterialType } from "$lib/api/dashboard/refrence_data/material_types";
import { companyStore } from "$lib/stores/company.svelte";
import { onMount } from 'svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: A76Class | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? "Editar Clase" : "Nueva Clase");
// Estado del formulario
let formData = $state({
class_code: item?.class_code || '',
description_es: item?.description_es || '',
description_en: item?.description_en || '',
material_key: item?.material_key || '',
unit_of_measure: item?.unit_of_measure || 'KG',
fraction: item?.fraction || '',
us_fraction: item?.us_fraction || '',
sub_key: item?.sub_key || '',
physical_review: item?.physical_review || 0,
iva_exempt_fraction: item?.iva_exempt_fraction || ''
});
let loading = $state(false);
let error = $state<string | null>(null);
let materialTypes = $state<MaterialType[]>([]);
let loadingMaterialTypes = $state(false);
// Variables para controlar los selects
let selectedUnitValue = $state<string>('KG');
let selectedMaterialValue = $state<string>('');
let selectedPhysicalReviewValue = $state<number>(0);
// Cargar tipos de materiales al montar
onMount(async () => {
loadingMaterialTypes = true;
try {
const response = await materialTypesApi.list(1, 100); // Cargar los primeros 100
if (response.data) {
materialTypes = response.data.items;
}
} catch (e) {
console.error('Error loading material types:', e);
} finally {
loadingMaterialTypes = false;
}
});
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = {
class_code: item.class_code,
description_es: item.description_es || '',
description_en: item.description_en || '',
material_key: item.material_key || '',
unit_of_measure: item.unit_of_measure,
fraction: item.fraction,
us_fraction: item.us_fraction,
sub_key: item.sub_key,
physical_review: item.physical_review,
iva_exempt_fraction: item.iva_exempt_fraction
};
// Actualizar valores de los selects
selectedUnitValue = item.unit_of_measure;
selectedMaterialValue = item.material_key || '';
selectedPhysicalReviewValue = item.physical_review;
} else {
// Reset para modo crear
formData = {
class_code: '',
description_es: '',
description_en: '',
material_key: '',
unit_of_measure: 'KG',
fraction: '',
us_fraction: '',
sub_key: '',
physical_review: 0,
iva_exempt_fraction: ''
};
// Resetear valores de los selects
selectedUnitValue = 'KG';
selectedMaterialValue = '';
selectedPhysicalReviewValue = 0;
}
error = null;
});
function handleOpenChange(newOpen: boolean) {
open = newOpen;
if (!newOpen) {
error = null;
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
// Validaciones básicas
if (!formData.class_code.trim()) {
error = 'El código de clase es requerido';
return;
}
if (!formData.fraction.trim()) {
error = 'La fracción es requerida';
return;
}
if (!formData.us_fraction.trim()) {
error = 'La fracción US es requerida';
return;
}
if (!formData.sub_key.trim()) {
error = 'La subclave es requerida';
return;
}
if (!formData.iva_exempt_fraction.trim()) {
error = 'La fracción exenta de IVA es requerida';
return;
}
loading = true;
error = null;
try {
let response;
if (isEdit && item) {
// Actualizar
const updateData: A76ClassUpdate = {
class_code: formData.class_code,
description_es: formData.description_es || null,
description_en: formData.description_en || null,
material_key: formData.material_key || null,
unit_of_measure: formData.unit_of_measure,
fraction: formData.fraction,
us_fraction: formData.us_fraction,
sub_key: formData.sub_key,
physical_review: formData.physical_review,
iva_exempt_fraction: formData.iva_exempt_fraction
};
response = await classesApi.update(item.id, updateData, companyId);
} else {
// Crear - usa el company_id como client_id
const createData: A76ClassCreate = {
company_id: companyId,
client_id: companyId, // Usa el mismo company_id como client_id
class_code: formData.class_code,
description_es: formData.description_es || null,
description_en: formData.description_en || null,
material_key: formData.material_key || null,
unit_of_measure: formData.unit_of_measure,
fraction: formData.fraction,
us_fraction: formData.us_fraction,
sub_key: formData.sub_key,
physical_review: formData.physical_review,
iva_exempt_fraction: formData.iva_exempt_fraction
};
response = await classesApi.create(createData, companyId);
}
if (response.error) {
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 class:', e);
} finally {
loading = false;
}
}
// Opciones de unidades de medida (puedes expandir esto)
const unitOptions = [
{ value: 'KG', label: 'Kilogramos (KG)' },
{ value: 'LB', label: 'Libras (LB)' },
{ value: 'MT', label: 'Metros (MT)' },
{ value: 'PZ', label: 'Piezas (PZ)' },
{ value: 'LT', label: 'Litros (LT)' },
{ value: 'M3', label: 'Metros Cúbicos (M3)' },
{ value: 'TON', label: 'Toneladas (TON)' }
];
// Funciones para obtener valores seleccionados
function getSelectedMaterialType() {
if (!formData.material_key) return null;
const found = materialTypes.find(mt => mt.key === formData.material_key);
return found ? { value: found.key, label: `${found.key} - ${found.description}` } : null;
}
function getSelectedUnit() {
return unitOptions.find(opt => opt.value === formData.unit_of_measure) || unitOptions[0];
}
function getSelectedPhysicalReview() {
return { value: formData.physical_review, label: formData.physical_review === 1 ? 'Sí' : 'No' };
}
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit ? 'Modifica los datos de la clase' : 'Completa los datos para crear una nueva clase'}
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-4 py-4">
<!-- Error Message -->
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Información de la compañía (solo lectura) -->
{#if companyStore.activeCompany}
<div class="rounded-md bg-blue-50 border border-blue-200 p-3">
<div class="flex items-center gap-2">
<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="text-blue-600"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<div>
<p class="text-sm font-medium text-blue-900">
{companyStore.activeCompany.name}
</p>
<p class="text-xs text-blue-600">
ID: {companyStore.activeCompany.id}
</p>
</div>
</div>
</div>
{/if}
<!-- Código de Clase -->
<div class="space-y-2">
<Label for="class_code" class="required">Código de Clase</Label>
<Input
id="class_code"
bind:value={formData.class_code}
placeholder="Ej: A76"
required
disabled={loading}
/>
</div>
<!-- Descripciones -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="description_es">Descripción (Español)</Label>
<Textarea
id="description_es"
bind:value={formData.description_es}
placeholder="Descripción en español"
disabled={loading}
rows={3}
/>
</div>
<div class="space-y-2">
<Label for="description_en">Descripción (Inglés)</Label>
<Textarea
id="description_en"
bind:value={formData.description_en}
placeholder="English description"
disabled={loading}
rows={3}
/>
</div>
</div>
<!-- Material Type -->
<div class="space-y-2">
<Label for="material_key">Tipo de Material</Label>
{#if loadingMaterialTypes}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
Cargando tipos de material...
</div>
{:else if materialTypes.length > 0}
<select
bind:value={formData.material_key}
disabled={loading}
class="border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
>
<option value="">Sin tipo de material</option>
{#each materialTypes as materialType}
<option value={materialType.key}>
{materialType.key} - {materialType.description}
</option>
{/each}
</select>
{:else}
<Input
id="material_key"
bind:value={formData.material_key}
placeholder="No hay tipos de material disponibles"
disabled={true}
/>
{/if}
</div>
<!-- Fracciones -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="fraction" class="required">Fracción</Label>
<Input
id="fraction"
bind:value={formData.fraction}
placeholder="Ej: 123123"
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="us_fraction" class="required">Fracción US</Label>
<Input
id="us_fraction"
bind:value={formData.us_fraction}
placeholder="Ej: 123123"
required
disabled={loading}
/>
</div>
</div>
<!-- Sub Key e IVA Exempt -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sub_key" class="required">Subclave</Label>
<Input
id="sub_key"
bind:value={formData.sub_key}
placeholder="Ej: 123"
required
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="iva_exempt_fraction" class="required">Fracción Exenta IVA</Label>
<Input
id="iva_exempt_fraction"
bind:value={formData.iva_exempt_fraction}
placeholder="Ej: 123"
required
disabled={loading}
/>
</div>
</div>
<!-- Unidad de Medida -->
<div class="space-y-2">
<Label for="unit_of_measure" class="required">Unidad de Medida</Label>
<select
bind:value={formData.unit_of_measure}
disabled={loading}
class="border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
>
{#each unitOptions as unit}
<option value={unit.value}>{unit.label}</option>
{/each}
</select>
</div>
<!-- Revisión Física -->
<div class="space-y-2">
<Label for="physical_review">Revisión Física</Label>
<select
bind:value={formData.physical_review}
disabled={loading}
class="border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
>
<option value={0}>No</option>
<option value={1}>Sí</option>
</select>
</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>
Guardando...
{:else}
{isEdit ? 'Actualizar' : 'Crear'}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<style>
:global(.required::after) {
content: " *";
color: hsl(var(--destructive));
}
</style>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes";
import { companyStore } from "$lib/stores/company.svelte";
import CreateEditDialog from "./create-edit-dialog.svelte";
let {
item,
onSuccess
}: {
item: A76Class;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<A76Class | null>(null);
async function handleDelete() {
if (!confirm(`¿Estás seguro de eliminar la clase "${item.class_code}"?`)) {
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
alert('No hay compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await classesApi.delete(item.id, companyId);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`Error al eliminar: ${response.error}`);
}
return;
}
// Éxito
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al eliminar";
alert(`Error: ${error}`);
console.error("Error deleting:", e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</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>
<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"
>
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" 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>
{:else}
<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="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dialog de edición -->
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

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

@@ -167,7 +167,7 @@ export function getSidebarData(): SidebarData {
}, },
{ {
title: m["sidebar.general_catalogs.classification"](), title: m["sidebar.general_catalogs.classification"](),
url: "#", url: "/dashboard/general_catalogs/classes",
}, },
{ {
title: m["sidebar.general_catalogs.identifiers"](), title: m["sidebar.general_catalogs.identifiers"](),

View File

@@ -0,0 +1,400 @@
<script lang="ts">
import { onMount } from 'svelte';
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { companyStore } from '$lib/stores/company.svelte';
import DataTable from '$lib/components/dashboard/classes/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/classes/columns.js';
import CreateEditDialog from '$lib/components/dashboard/classes/create-edit-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { browser } from '$app/environment';
// Estado para la lista de classes
let allItems = $state<A76Class[]>([]);
let currentPage = $state(1);
let pageSize = $state(50);
let totalItems = $state(0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(null);
// Estado para filtros
let filters = $state({
class_code: '',
client_id: ''
});
// Estado para el dialog de crear
let createDialogOpen = $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);
}
}
// Esperar a que el companyStore esté inicializado antes de cargar datos
const checkAndLoad = () => {
if (companyStore.activeCompany) {
loadInitialData();
} else {
// Si no hay compañía, esperar un poco y reintentar
setTimeout(checkAndLoad, 100);
}
};
checkAndLoad();
// También escuchar cambios de compañía para recargar
const handleCompanyChange = () => {
loadInitialData();
};
window.addEventListener('companyChanged', handleCompanyChange);
return () => {
window.removeEventListener('companyChanged', handleCompanyChange);
};
});
async function loadInitialData() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada. Por favor selecciona una compañía en el sidebar.';
loading = false;
return;
}
loading = true;
error = null;
try {
const response = await classesApi.list({
company_id: companyId,
page: 1,
page_size: pageSize
});
if (response.error) {
console.error('📊 [Classes Page] Error en loadInitialData:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data) {
allItems = response.data.items;
currentPage = response.data.page;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando los datos';
console.error('📊 [Classes Page] Error loading initial data:', e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loading || !hasMore) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
loading = true;
error = null;
try {
const response = await classesApi.list({
company_id: companyId,
page: currentPage + 1,
page_size: pageSize
});
if (response.error) {
console.error('📊 [Classes Page] Error en loadMore:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
allItems = [...allItems, ...response.data.items];
currentPage++;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error cargando más datos';
console.error('📊 [Classes Page] Error loading more:', e);
} finally {
loading = false;
}
}
async function applyFilters() {
// Reset y recargar con filtros
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = null;
try {
// Aquí podrías agregar filtros adicionales al endpoint si el backend los soporta
const response = await classesApi.list({
company_id: companyId,
page: 1,
page_size: pageSize
});
if (response.error) {
console.error('📊 [Classes Page] Error aplicando filtros:', response.error);
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
error = response.error;
}
return;
}
if (response.data) {
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (e) {
error = 'Error aplicando filtros';
console.error('📊 [Classes Page] Error applying filters:', e);
} finally {
loading = false;
}
}
function clearFilters() {
filters = {
class_code: '',
client_id: ''
};
loadInitialData();
}
function reloadData() {
loadInitialData();
}
function handleCreateClick() {
createDialogOpen = true;
}
function handleSuccess() {
// Recargar datos después de crear/editar/eliminar
reloadData();
}
function handleDialogSuccess() {
createDialogOpen = false;
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">Clases A76</h1>
<p class="text-muted-foreground">
Gestiona las clases de materiales del sistema
</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>
Nueva Clase
</Button>
</div>
<!-- Filtros -->
<Card.Root>
<Card.Header>
<Card.Title>Filtros</Card.Title>
<Card.Description>Filtra las clases por diferentes criterios</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={(e) => { e.preventDefault(); applyFilters(); }} class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="filter-class-code">Código de Clase</Label>
<Input
id="filter-class-code"
bind:value={filters.class_code}
placeholder="Ej: A76"
/>
</div>
<div class="space-y-2">
<Label for="filter-client">ID del Cliente</Label>
<Input
id="filter-client"
type="number"
bind:value={filters.client_id}
placeholder="Ej: 123"
/>
</div>
<div class="flex items-end gap-2 md:col-span-2">
<Button type="submit" disabled={loading} class="flex-1">
<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"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filtrar
</Button>
<Button type="button" variant="outline" onclick={clearFilters} disabled={loading}>
<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"
>
<path d="M3 6h18" />
<path d="m19 6-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<!-- 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 Clases</Card.Title>
<Card.Description>
Mostrando {allItems.length} de {totalItems} registros
</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>
<!-- Dialog de crear nueva clase -->
<CreateEditDialog bind:open={createDialogOpen} onSuccess={handleDialogSuccess} />