feature/app-selector
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""add system column to classes and parts
|
||||
|
||||
Revision ID: a7b8c9d0e1f2
|
||||
Revises: d2e3f4a5b6c7
|
||||
Create Date: 2026-05-26 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a7b8c9d0e1f2"
|
||||
down_revision: Union[str, None] = "d2e3f4a5b6c7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Columna system en a76.classes — indica si la clase pertenece a SCAF (fixed_asset) o SCAII (inventory)
|
||||
op.add_column(
|
||||
"classes",
|
||||
sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"),
|
||||
schema="a76",
|
||||
)
|
||||
# Columna system en a76.parts — misma discriminación por sistema
|
||||
op.add_column(
|
||||
"parts",
|
||||
sa.Column("system", sa.String(length=12), nullable=False, server_default="fixed_asset"),
|
||||
schema="a76",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("classes", "system", schema="a76")
|
||||
op.drop_column("parts", "system", schema="a76")
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import inspect
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource
|
||||
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
|
||||
|
||||
from api.v1.common.catalog_validation_errors import CatalogValidationError
|
||||
@@ -175,11 +175,16 @@ class TenantCRUDRoutes(
|
||||
# Excluimos los parámetros estándar de paginación y control
|
||||
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"}
|
||||
filters = {
|
||||
k: v
|
||||
for k, v in request.query_params.items()
|
||||
k: v
|
||||
for k, v in request.query_params.items()
|
||||
if k not in standard_params and v is not None and v != ""
|
||||
}
|
||||
|
||||
# Inyectar active_system (header/cookie) si no viene por query param
|
||||
active_system = get_active_system(request)
|
||||
if active_system and "system" not in filters:
|
||||
filters["system"] = active_system
|
||||
|
||||
# Determine what parameters the service method accepts
|
||||
sig = inspect.signature(self.service.get_all)
|
||||
kwargs = {}
|
||||
@@ -383,6 +388,7 @@ class TenantCRUDRoutes(
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_child_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
@@ -396,6 +402,12 @@ class TenantCRUDRoutes(
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
# For child resources, parent_id validation would go here
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
@@ -429,6 +441,7 @@ class TenantCRUDRoutes(
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_parent_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
@@ -441,6 +454,13 @@ class TenantCRUDRoutes(
|
||||
self.create_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
|
||||
@@ -46,6 +46,10 @@ class ClassCreateDTO(BaseModel):
|
||||
is_active: Optional[bool] = Field(
|
||||
True, description="Indicates if the class is active (default: true)"
|
||||
)
|
||||
system: str = Field(
|
||||
default="fixed_asset", max_length=12,
|
||||
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -125,6 +129,10 @@ class ClassUpdateDTO(BaseModel):
|
||||
is_active: Optional[bool] = Field(
|
||||
True, description="Indicates if the class is active (default: true)"
|
||||
)
|
||||
system: Optional[str] = Field(
|
||||
None, max_length=12,
|
||||
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
|
||||
|
||||
@@ -146,6 +154,7 @@ class ClassResponseDTO(BaseModel):
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
system: str = "fixed_asset"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -226,9 +235,10 @@ class ClassWithFADataResponse(BaseModel):
|
||||
sub_key: Optional[str] = None
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
system: str = "fixed_asset"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# FA-specific fields (embedded from a24.fa_classes)
|
||||
fa_class_id: Optional[int] = None
|
||||
import_tariff_code: Optional[str] = None
|
||||
|
||||
@@ -88,6 +88,9 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
default=True, server_default="true", nullable=False
|
||||
) # Campo para habilitar/deshabilitar clases sin eliminarlas
|
||||
|
||||
# Sistema al que pertenece la clase: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
|
||||
|
||||
# Relationships
|
||||
material_type: Mapped[Optional["MaterialType"]] = relationship(
|
||||
foreign_keys=[material_key]
|
||||
|
||||
@@ -3,11 +3,11 @@ Endpoints API para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from core.security import get_current_user, get_active_system
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
|
||||
|
||||
from .dto import (
|
||||
@@ -37,6 +37,7 @@ router.include_router(imports_router, prefix="/imports", tags=["a76 / classes /
|
||||
tags=["a76 / classes"],
|
||||
)
|
||||
async def get_classes_with_fa_data(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
|
||||
@@ -54,28 +55,31 @@ async def get_classes_with_fa_data(
|
||||
This endpoint is optimized for the fixed-asset-classes view.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"])
|
||||
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
if any([class_code, description, material_key, fraction]):
|
||||
filters = {}
|
||||
if class_code:
|
||||
filters["class_code"] = class_code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
if material_key:
|
||||
filters["material_key"] = material_key
|
||||
if fraction:
|
||||
filters["fraction"] = fraction
|
||||
|
||||
filters: Dict[str, Any] = {}
|
||||
if class_code:
|
||||
filters["class_code"] = class_code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
if material_key:
|
||||
filters["material_key"] = material_key
|
||||
if fraction:
|
||||
filters["fraction"] = fraction
|
||||
|
||||
# Sistema activo desde header/cookie (TenantMiddleware no setea request.state en /api/)
|
||||
active_system = get_active_system(request)
|
||||
if active_system:
|
||||
filters["system"] = active_system
|
||||
|
||||
classes_with_fa, total = ClassService.get_all_with_fa_data(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters,
|
||||
filters=filters if filters else None,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -96,17 +100,30 @@ async def get_classes_with_fa_data(
|
||||
tags=["a76 / classes"],
|
||||
)
|
||||
async def create_fa_class(
|
||||
request: Request,
|
||||
class_data: ClassCreateDTOFA,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a fixed asset class (both base class and FA extension)"""
|
||||
|
||||
active_system = get_active_system(request)
|
||||
if active_system == "inventory":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Solo se pueden crear clases de activo fijo con el módulo de Activo Fijo (SCAF) activo. "
|
||||
"Cambie de aplicación e intente de nuevo."
|
||||
),
|
||||
)
|
||||
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.create"])
|
||||
|
||||
|
||||
# Siempre persistir como activo fijo; no depender del default del DTO ni del body del cliente
|
||||
class_data = class_data.model_copy(update={"system": "fixed_asset"})
|
||||
|
||||
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
# Now include generic CRUD routes
|
||||
|
||||
@@ -82,7 +82,9 @@ class ClassService:
|
||||
query = query.filter(
|
||||
Class.physical_review == filters["physical_review"]
|
||||
)
|
||||
|
||||
if filters.get("system"):
|
||||
query = query.filter(Class.system == filters["system"])
|
||||
|
||||
# Apply sorting
|
||||
if sort_by:
|
||||
column = getattr(Class, sort_by, None)
|
||||
@@ -161,7 +163,9 @@ class ClassService:
|
||||
)
|
||||
if filters.get("fraction"):
|
||||
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
|
||||
|
||||
if filters.get("system"):
|
||||
query = query.filter(Class.system == filters["system"])
|
||||
|
||||
# Apply sorting
|
||||
if sort_by:
|
||||
# Check if sort_by belongs to Class or QClasses
|
||||
@@ -203,6 +207,7 @@ class ClassService:
|
||||
"sub_key": base_class.sub_key,
|
||||
"physical_review": base_class.physical_review,
|
||||
"iva_exempt_fraction": base_class.iva_exempt_fraction,
|
||||
"system": base_class.system,
|
||||
"created_at": base_class.created_at,
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields (None if no FA record exists)
|
||||
@@ -399,7 +404,7 @@ class ClassService:
|
||||
base_fields = {
|
||||
"class_code", "description_es", "description_en",
|
||||
"material_key", "unit_of_measure", "fraction", "us_fraction",
|
||||
"sub_key", "physical_review", "iva_exempt_fraction"
|
||||
"sub_key", "physical_review", "iva_exempt_fraction", "system"
|
||||
}
|
||||
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
|
||||
|
||||
@@ -442,6 +447,7 @@ class ClassService:
|
||||
"sub_key": base_class.sub_key,
|
||||
"physical_review": base_class.physical_review,
|
||||
"iva_exempt_fraction": base_class.iva_exempt_fraction,
|
||||
"system": base_class.system,
|
||||
"created_at": base_class.created_at,
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Dict, Any, Literal, Optional
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.exceptions import BaseAPIException
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource, get_active_system
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Request
|
||||
from sqlalchemy import func, or_, and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -181,6 +181,7 @@ def get_remesa_suggestion(
|
||||
|
||||
@router.post("/invoices/", response_model=schemas.InvoiceHeaderResponse)
|
||||
def create_invoice(
|
||||
request: Request,
|
||||
data: schemas.InvoiceHeaderCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -188,11 +189,16 @@ def create_invoice(
|
||||
):
|
||||
try:
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
|
||||
# Sistema autoritativo desde el contexto activo (header/cookie)
|
||||
active_system = get_active_system(request)
|
||||
if active_system:
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
# Validamos usando los datos que vienen en el body (payload)
|
||||
perm_base = get_invoice_permission_base(data.operation_type, data.invoice_type)
|
||||
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"])
|
||||
|
||||
|
||||
return services.InvoiceService.create(db, data, tenant_id, company_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -506,6 +512,7 @@ def interface_cp_genesis(
|
||||
|
||||
@router.get("/invoices/", response_model=schemas.InvoiceHeaderListResponse)
|
||||
def list_invoices(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
|
||||
@@ -575,9 +582,10 @@ def list_invoices(
|
||||
"pedimento": pedimento,
|
||||
"project_number": project_number,
|
||||
"year": year,
|
||||
"allowed_types": allowed_filters
|
||||
"allowed_types": allowed_filters,
|
||||
"system": get_active_system(request),
|
||||
}
|
||||
|
||||
|
||||
filters = {k: v for k, v in filters.items() if v is not None}
|
||||
|
||||
items, total = services.InvoiceService.get_all(
|
||||
|
||||
@@ -369,6 +369,10 @@ class InvoiceService:
|
||||
if not filters.get("invoice_type") and ot_exp_val == "exp":
|
||||
query = query.filter(models.InvoiceHeader.invoice_type != "REPAR")
|
||||
|
||||
# Filtro por sistema activo (SCAF / SCAII)
|
||||
if filters.get("system"):
|
||||
query = query.filter(models.InvoiceHeader.system == filters["system"])
|
||||
|
||||
# Filtro por permisos granulares (allowed_types)
|
||||
if "allowed_types" in filters:
|
||||
allowed = filters["allowed_types"]
|
||||
|
||||
@@ -174,6 +174,9 @@ class PartBase(BaseModel):
|
||||
is_active: bool = True
|
||||
part_photo: Optional[str] = None
|
||||
|
||||
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: str = Field(default="fixed_asset", max_length=12)
|
||||
|
||||
# Anidados
|
||||
fa_data: Optional[FaDataDTO] = None
|
||||
inv_data: Optional[InvDataDTO] = None
|
||||
|
||||
@@ -97,6 +97,9 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
|
||||
)
|
||||
part_photo: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
|
||||
|
||||
creation_date: Mapped[Optional[int]] = mapped_column()
|
||||
modification_date: Mapped[Optional[int]] = mapped_column()
|
||||
modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
|
||||
@@ -96,6 +96,8 @@ class PartService:
|
||||
Part.commercial_part_number.ilike(search)
|
||||
)
|
||||
)
|
||||
if filters.get("system"):
|
||||
query = query.filter(Part.system == filters["system"])
|
||||
# Otros filtros...
|
||||
|
||||
# Apply sorting
|
||||
|
||||
@@ -104,18 +104,45 @@ async def get_my_permissions(
|
||||
# Nota: bootstrap_super_admin ya hace commit e intenta no duplicar si el rol ya existe
|
||||
permission_service.bootstrap_super_admin(user_id, company_id)
|
||||
|
||||
# 4. Obtener permisos finales
|
||||
# 4. Sincronizar permisos de sistema desde claim de Keycloak (si viene en el token)
|
||||
# El claim 'allowed_systems' puede contener ["fixed_asset"], ["inventory"], o ambos.
|
||||
# Solo sincroniza si el claim está presente; si no, los permisos de sistema se asignan
|
||||
# manualmente desde el panel de administración.
|
||||
_VALID_SYSTEMS = frozenset(("fixed_asset", "inventory"))
|
||||
keycloak_systems = current_user.get("allowed_systems") or []
|
||||
if keycloak_systems:
|
||||
for sys_code in keycloak_systems:
|
||||
if sys_code in _VALID_SYSTEMS:
|
||||
try:
|
||||
permission_service.grant_direct_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_code=f"system.{sys_code}.access",
|
||||
assigned_by="keycloak_sync",
|
||||
)
|
||||
except ValueError:
|
||||
# El permiso de sistema aún no existe en BD (sync pendiente); se ignora.
|
||||
pass
|
||||
|
||||
# 5. Obtener permisos finales
|
||||
permissions = permission_service.get_user_permissions(user_id, company_id)
|
||||
|
||||
# 5. Obtener roles
|
||||
roles = permission_service.get_user_roles(user_id, company_id)
|
||||
role_names = [role.name for role in roles]
|
||||
|
||||
# 6. Derivar sistemas permitidos desde los permisos de sistema
|
||||
allowed_systems = [
|
||||
sys for sys in ("inventory", "fixed_asset")
|
||||
if f"system.{sys}.access" in permissions
|
||||
]
|
||||
|
||||
return UserPermissionsResponse(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permissions=list(permissions),
|
||||
roles=role_names,
|
||||
allowed_systems=allowed_systems,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ class UserPermissionsResponse(BaseModel):
|
||||
roles: List[str] = Field(
|
||||
default_factory=list, description="Lista de nombres de roles del usuario"
|
||||
)
|
||||
allowed_systems: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory",
|
||||
)
|
||||
|
||||
|
||||
class UserCompanyRoleResponse(BaseModel):
|
||||
|
||||
@@ -200,6 +200,14 @@ permissions_csv = [
|
||||
("csv_upload.process", "Procesar Cargas Masivas CSV", "csv_upload", "process"),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ACCESO A SISTEMAS (SCAII / SCAF)
|
||||
# ============================================================================
|
||||
permissions_system_access = [
|
||||
("system.inventory.access", "Acceso al sistema Inventario (SCAII)", "system", "access"),
|
||||
("system.fixed_asset.access", "Acceso al sistema Activo Fijo (SCAF)", "system", "access"),
|
||||
]
|
||||
|
||||
def register_core_permissions():
|
||||
"""Registra los permisos granulados de la aplicación según Sidebar."""
|
||||
registry.register_many(permissions_audit)
|
||||
@@ -222,6 +230,7 @@ def register_core_permissions():
|
||||
registry.register_many(permissions_settings)
|
||||
registry.register_many(permissions_help)
|
||||
registry.register_many(permissions_csv)
|
||||
registry.register_many(permissions_system_access)
|
||||
|
||||
# Ejecutar registro al importar este módulo
|
||||
register_core_permissions()
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .security import get_tenant_from_token, verify_token
|
||||
from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +53,7 @@ def _extract_company_id(request: Request) -> Optional[int]:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para extraer tenant_id y user_info del token.
|
||||
@@ -96,6 +97,7 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.user_info = user_info
|
||||
request.state.company_id = _extract_company_id(request)
|
||||
request.state.active_system = get_active_system(request)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Tenant validation error: {str(e)}")
|
||||
return JSONResponse(
|
||||
|
||||
@@ -33,6 +33,17 @@ _tenant_id_hub_by_local: Dict[int, int] = {}
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
# Sistemas válidos del selector de app (SCAF / SCAII)
|
||||
_VALID_SYSTEMS = frozenset(("fixed_asset", "inventory"))
|
||||
|
||||
|
||||
def get_active_system(request: Request) -> Optional[str]:
|
||||
"""Sistema activo (SCAF/SCAII): header ``X-Active-System`` (autoritativo del cliente)
|
||||
con fallback a la cookie ``active_system``. No depende de ``request.state`` porque
|
||||
``TenantMiddleware`` hace short-circuit en rutas ``/api/``."""
|
||||
value = request.headers.get("x-active-system") or request.cookies.get("active_system")
|
||||
return value if value in _VALID_SYSTEMS else None
|
||||
|
||||
|
||||
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -229,6 +229,15 @@ function buildAuthHeaders(baseHeaders: Record<string, string> = {}): Record<stri
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
|
||||
// active_system (SCAF/SCAII): cookie no-HttpOnly → header explícito para el backend.
|
||||
const activeSystem = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('active_system='))
|
||||
?.split('=')[1];
|
||||
if (activeSystem) {
|
||||
headers['X-Active-System'] = activeSystem;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface User {
|
||||
tenantId?: number;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
allowedSystems: string[]; // sistemas a los que tiene acceso: "fixed_asset" | "inventory"
|
||||
// Cache management
|
||||
profileSyncedAt?: number; // timestamp en ms para cache TTL
|
||||
}
|
||||
@@ -346,6 +347,7 @@ const updateAuthState = async () => {
|
||||
tenantId,
|
||||
roles,
|
||||
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms,
|
||||
allowedSystems: previousUser?.allowedSystems ?? [],
|
||||
profileSyncedAt: previousUser?.profileSyncedAt
|
||||
};
|
||||
|
||||
@@ -481,7 +483,7 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
|
||||
if (!browser || !Number.isFinite(companyId)) return;
|
||||
try {
|
||||
const { api } = await import('./api');
|
||||
const res = await api.get<{ permissions: string[] }>(
|
||||
const res = await api.get<{ permissions: string[]; allowed_systems?: string[] }>(
|
||||
`/v1/core/permissions/me?company_id=${companyId}`
|
||||
);
|
||||
if (res.error || res.data === undefined) return;
|
||||
@@ -489,10 +491,24 @@ export async function syncCompanyPermissions(companyId: number): Promise<void> {
|
||||
if (!Array.isArray(perms)) return;
|
||||
const state = get(authStore);
|
||||
if (!state.user) return;
|
||||
const { systemStore } = await import('./stores/system.svelte');
|
||||
const allowedSystems = (res.data.allowed_systems ?? []) as import('./stores/system.svelte').SystemType[];
|
||||
|
||||
authStore.setUser({
|
||||
...state.user,
|
||||
permissions: perms
|
||||
permissions: perms,
|
||||
// Preservar allowedSystems del SSR si el API no los retorna (JWT sin claim, seed pendiente)
|
||||
allowedSystems: allowedSystems.length > 0 ? allowedSystems : (state.user.allowedSystems ?? [])
|
||||
});
|
||||
|
||||
// Solo reinicializar el systemStore si el API retorna sistemas explícitos.
|
||||
// Si está vacío, preservar el estado establecido por SSR para evitar resetear activeSystem a null.
|
||||
if (allowedSystems.length > 0) {
|
||||
const cookieSystem = typeof document !== 'undefined'
|
||||
? document.cookie.match(/(?:^|;\s*)active_system=([^;]+)/)?.[1] ?? null
|
||||
: null;
|
||||
systemStore.initialize(allowedSystems, cookieSystem);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth] syncCompanyPermissions:', e);
|
||||
} finally {
|
||||
@@ -591,6 +607,7 @@ const loadUserInfo = async (token: string) => {
|
||||
tenantId: d.tenant_id ?? previousUser?.tenantId,
|
||||
roles: d.roles ?? previousUser?.roles ?? [],
|
||||
permissions: d.permissions ?? previousUser?.permissions ?? [],
|
||||
allowedSystems: previousUser?.allowedSystems ?? [],
|
||||
profileSyncedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,9 +36,18 @@
|
||||
initialData?: any;
|
||||
externalError?: string;
|
||||
onClearError?: () => void;
|
||||
mode?: 'fixed_asset' | 'inventory';
|
||||
}
|
||||
|
||||
let { onSave, onCancel, initialData, externalError = '', onClearError }: Props = $props();
|
||||
let {
|
||||
onSave,
|
||||
onCancel,
|
||||
initialData,
|
||||
externalError = '',
|
||||
onClearError,
|
||||
mode = 'fixed_asset'
|
||||
}: Props = $props();
|
||||
const isInventoryMode = $derived(mode === 'inventory');
|
||||
|
||||
// Tipo para unidad de medida con todos los campos
|
||||
interface UnitOfMeasure {
|
||||
@@ -286,7 +295,9 @@
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
const response = await materialTypesApi.list(companyId, 1, 100, 'ACTIVO FIJO');
|
||||
const response = isInventoryMode
|
||||
? await materialTypesApi.list(companyId, 1, 100)
|
||||
: await materialTypesApi.list(companyId, 1, 100, 'ACTIVO FIJO');
|
||||
if (response.data) {
|
||||
materialTypes = response.data.items;
|
||||
}
|
||||
@@ -546,7 +557,9 @@
|
||||
}
|
||||
|
||||
if (!formData.material_key?.trim()) {
|
||||
errors.material_key = 'El tipo de activo fijo es obligatorio';
|
||||
errors.material_key = isInventoryMode
|
||||
? 'El tipo de material es obligatorio'
|
||||
: 'El tipo de activo fijo es obligatorio';
|
||||
}
|
||||
|
||||
if (!formData.unit_of_measure?.trim()) {
|
||||
@@ -585,7 +598,9 @@
|
||||
break;
|
||||
case 'material_key':
|
||||
if (!formData.material_key?.trim()) {
|
||||
errors.material_key = 'El tipo de activo fijo es obligatorio';
|
||||
errors.material_key = isInventoryMode
|
||||
? 'El tipo de material es obligatorio'
|
||||
: 'El tipo de activo fijo es obligatorio';
|
||||
} else {
|
||||
delete errors.material_key;
|
||||
}
|
||||
@@ -685,7 +700,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="material_key" class="font-bold">
|
||||
Tipo de Activo Fijo: <span class="text-red-500">*</span>
|
||||
{isInventoryMode ? 'Tipo de Material' : 'Tipo de Activo Fijo'}: <span class="text-red-500">*</span>
|
||||
</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -833,48 +848,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasa depreciación y ECCN (misma fila que en pantalla legacy) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="annual_depreciation_rate">Tasa Anual de Depreciación:</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !isInventoryMode}
|
||||
<!-- Campos exclusivos del flujo de Activo Fijo -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="annual_depreciation_rate">Tasa Anual de Depreciación:</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="annual_depreciation_rate"
|
||||
type="number"
|
||||
bind:value={formData.annual_depreciation_rate}
|
||||
placeholder="0.00"
|
||||
class="flex-1"
|
||||
step="0.01"
|
||||
/>
|
||||
<span class="text-sm">%</span>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openDepreciationSearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="eccn_code">ECCN:</Label>
|
||||
<Input
|
||||
id="annual_depreciation_rate"
|
||||
type="number"
|
||||
bind:value={formData.annual_depreciation_rate}
|
||||
placeholder="0.00"
|
||||
class="flex-1"
|
||||
step="0.01"
|
||||
id="eccn_code"
|
||||
bind:value={formData.eccn_code}
|
||||
placeholder="Código ECCN"
|
||||
class="w-full uppercase"
|
||||
maxlength={20}
|
||||
/>
|
||||
<span class="text-sm">%</span>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openDepreciationSearch}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clave FDA -->
|
||||
<div class="space-y-2">
|
||||
<Label for="fda_key">Clave FDA:</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="fda_key" bind:value={formData.fda_key} placeholder="Clave FDA" class="flex-1" />
|
||||
<Button type="button" variant="outline" size="icon" onclick={openFDASearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="eccn_code">ECCN:</Label>
|
||||
<Input
|
||||
id="eccn_code"
|
||||
bind:value={formData.eccn_code}
|
||||
placeholder="Código ECCN"
|
||||
class="w-full uppercase"
|
||||
maxlength={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clave FDA -->
|
||||
<div class="space-y-2">
|
||||
<Label for="fda_key">Clave FDA:</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="fda_key" bind:value={formData.fda_key} placeholder="Clave FDA" class="flex-1" />
|
||||
<Button type="button" variant="outline" size="icon" onclick={openFDASearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fracción exenta de IVA (Sí / No + código opcional máx. 4) -->
|
||||
<div class="space-y-2">
|
||||
@@ -930,7 +947,7 @@
|
||||
<Dialog.Root bind:open={showMaterialDialog}>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>CATALOGO DE ACTIVO FIJO</Dialog.Title>
|
||||
<Dialog.Title>{isInventoryMode ? 'CATALOGO DE MATERIALES' : 'CATALOGO DE ACTIVO FIJO'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { goto } from '$app/navigation';
|
||||
import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData } from '$lib/api/dashboard/a76/invoices';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import { normalizeInvoiceFieldPath } from './focus-invoice-field';
|
||||
import { systemStore } from '$lib/stores/system.svelte';
|
||||
|
||||
interface FormDataSet {
|
||||
InvoiceTopFieldsFormData: any;
|
||||
@@ -161,9 +162,14 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvo
|
||||
function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateInvoiceData {
|
||||
const { InvoiceTopFieldsFormData, generalFormData, observationFormData, itemsFormData, othersFormData, continuationFormData } = formData;
|
||||
|
||||
// Sistema activo del selector de app (SCAF / SCAII).
|
||||
// Si por alguna razón no está inicializado, mantenemos el comportamiento actual.
|
||||
const activeSystem = systemStore.activeSystem;
|
||||
const resolvedSystem = activeSystem ?? 'fixed_asset';
|
||||
|
||||
const payload: any = {
|
||||
// Datos generales desde InvoiceTopFieldsFormData
|
||||
system: 'fixed_asset',
|
||||
system: resolvedSystem,
|
||||
operation_type: InvoiceTopFieldsFormData?.operation_type || undefined,
|
||||
invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined,
|
||||
document_type: generalFormData?.document_type || undefined,
|
||||
|
||||
73
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal file
73
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
|
||||
import PackageIcon from '@lucide/svelte/icons/package';
|
||||
import BarChart3Icon from '@lucide/svelte/icons/bar-chart-3';
|
||||
import { systemStore, SYSTEM_LABELS, type SystemType } from '$lib/stores/system.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
const ICONS: Record<SystemType, any> = {
|
||||
fixed_asset: PackageIcon,
|
||||
inventory: BarChart3Icon,
|
||||
};
|
||||
|
||||
async function switchSystem(sys: SystemType) {
|
||||
if (sys === systemStore.activeSystem || systemStore.switching) return;
|
||||
const ok = await systemStore.setActiveSystem(sys);
|
||||
if (ok) await invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md
|
||||
text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground
|
||||
transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring
|
||||
data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
title="Aplicaciones"
|
||||
aria-label="Abrir selector de aplicaciones"
|
||||
>
|
||||
<LayoutGridIcon class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
class="w-64 rounded-xl p-3"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p class="mb-3 px-1 text-xs font-medium text-muted-foreground">Tus aplicaciones</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each systemStore.allowedSystems as sys (sys)}
|
||||
{@const label = SYSTEM_LABELS[sys]}
|
||||
{@const Icon = ICONS[sys]}
|
||||
{@const active = systemStore.activeSystem === sys}
|
||||
{@const canSwitch = systemStore.canSwitch}
|
||||
<button
|
||||
onclick={() => switchSystem(sys)}
|
||||
disabled={!canSwitch || systemStore.switching}
|
||||
class="flex flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-colors
|
||||
disabled:cursor-default
|
||||
{canSwitch ? 'hover:bg-accent' : ''}
|
||||
{active ? 'ring-2 ring-primary bg-accent/50' : ''}
|
||||
{systemStore.switching ? 'opacity-50' : ''}"
|
||||
>
|
||||
<div class="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Icon class="size-5" />
|
||||
</div>
|
||||
<span class="text-sm font-medium leading-tight">{label.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{label.code}</span>
|
||||
{#if active}
|
||||
<CheckIcon class="size-3 text-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -8,6 +8,8 @@
|
||||
import NavProjects from "./nav-projects.svelte";
|
||||
import NavUser from "./nav-user.svelte";
|
||||
import TeamSwitcher from "./team-switcher.svelte";
|
||||
import AppLauncher from "./app-launcher.svelte";
|
||||
import { systemStore } from "$lib/stores/system.svelte";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ import {
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import { Title } from '../ui/alert';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
permission?: string;
|
||||
systemContext?: SystemContext;
|
||||
}
|
||||
|
||||
export interface NavMainItem {
|
||||
@@ -35,6 +38,7 @@ export interface NavMainItem {
|
||||
icon: any;
|
||||
isActive?: boolean;
|
||||
permission?: string;
|
||||
systemContext?: SystemContext;
|
||||
items?: NavItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import { authStore, userHasPermission } from '$lib/auth';
|
||||
import { systemStore } from '$lib/stores/system.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let {
|
||||
@@ -16,10 +17,12 @@
|
||||
icon?: any;
|
||||
isActive?: boolean;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
}[];
|
||||
}[];
|
||||
} = $props();
|
||||
@@ -29,13 +32,20 @@
|
||||
return pathname === url || pathname.startsWith(url + '/');
|
||||
}
|
||||
|
||||
// Filtrar items según permisos (si el item tiene la propiedad 'permission')
|
||||
function matchesSystem(ctx: 'fixed_asset' | 'inventory' | undefined): boolean {
|
||||
if (!ctx) return true;
|
||||
if (!systemStore.activeSystem) return false;
|
||||
return ctx === systemStore.activeSystem;
|
||||
}
|
||||
|
||||
// Filtrar items según permisos y sistema activo
|
||||
const filteredItems = $derived(
|
||||
items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
items: item.items?.filter((subItem) => {
|
||||
if (subItem.permission && !userHasPermission($authStore.user, subItem.permission)) return false;
|
||||
if (!matchesSystem(subItem.systemContext)) return false;
|
||||
return true;
|
||||
})
|
||||
}))
|
||||
@@ -43,7 +53,10 @@
|
||||
// 1. Filtrar por permiso explícito del item principal
|
||||
if (item.permission && !userHasPermission($authStore.user, item.permission)) return false;
|
||||
|
||||
// 2. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
|
||||
// 2. Filtrar por sistema activo
|
||||
if (!matchesSystem(item.systemContext)) return false;
|
||||
|
||||
// 3. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
|
||||
if (item.url === '#' && item.items && item.items.length === 0) return false;
|
||||
|
||||
return true;
|
||||
|
||||
@@ -81,16 +81,18 @@ export function clearAuthTokens(cookies: Cookies) {
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete('id_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('active_system', { path: '/' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea headers de autorización con el token Bearer
|
||||
*/
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string, activeSystem?: string) {
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
|
||||
...additionalHeaders
|
||||
};
|
||||
}
|
||||
@@ -169,6 +171,8 @@ export async function authenticatedFetch(
|
||||
|
||||
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
// Sistema activo (SCAF/SCAII) para reenviar al backend vía header
|
||||
const activeSystem = cookies.get('active_system');
|
||||
|
||||
// Crear AbortController para timeout
|
||||
const controller = new AbortController();
|
||||
@@ -181,8 +185,12 @@ export async function authenticatedFetch(
|
||||
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = isFormData
|
||||
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
|
||||
? {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
|
||||
...(options.headers as Record<string, string> || {})
|
||||
}
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride, activeSystem);
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
@@ -212,8 +220,12 @@ export async function authenticatedFetch(
|
||||
|
||||
// Si el body es FormData, no incluir Content-Type
|
||||
const newHeaders = isFormData
|
||||
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
|
||||
? {
|
||||
'Authorization': `Bearer ${newToken}`,
|
||||
...(activeSystem ? { 'X-Active-System': activeSystem } : {}),
|
||||
...(options.headers as Record<string, string> || {})
|
||||
}
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride, activeSystem);
|
||||
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
|
||||
130
frontend/src/lib/server/system-gate.ts
Normal file
130
frontend/src/lib/server/system-gate.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { redirect, type Cookies } from '@sveltejs/kit';
|
||||
import type { SystemType } from '$lib/stores/system.svelte';
|
||||
import { authenticatedFetch } from '$lib/server/api';
|
||||
import { getWorkspaceBaseUrl } from '$lib/server/workspace-auth';
|
||||
|
||||
const VALID_SYSTEMS = new Set<SystemType>(['fixed_asset', 'inventory']);
|
||||
|
||||
export function isValidSystem(value: string | null | undefined): value is SystemType {
|
||||
return typeof value === 'string' && VALID_SYSTEMS.has(value as SystemType);
|
||||
}
|
||||
|
||||
function parseSystemsArray(raw: unknown): SystemType[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((s): s is SystemType => typeof s === 'string' && isValidSystem(s));
|
||||
}
|
||||
|
||||
/** Decodifica el payload del JWT (sin verificar firma; el token ya fue validado vía Hub). */
|
||||
export function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
||||
const json = Buffer.from(padded, 'base64').toString('utf8');
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Combina claims del JWT con la respuesta de /auth/me (Hub). */
|
||||
export function mergeTokenClaims(
|
||||
userData: Record<string, unknown> | null | undefined,
|
||||
accessToken: string
|
||||
): Record<string, unknown> {
|
||||
const jwtClaims = decodeJwtPayload(accessToken) ?? {};
|
||||
return { ...jwtClaims, ...(userData ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sistemas permitidos según el token (claim `allowed_systems`).
|
||||
* El Hub/Keycloak lo incluye cuando el usuario entra desde Workspace.
|
||||
*/
|
||||
export function extractAllowedSystemsFromToken(
|
||||
tokenClaims: Record<string, unknown> | null | undefined
|
||||
): SystemType[] {
|
||||
if (!tokenClaims) return [];
|
||||
return parseSystemsArray(tokenClaims.allowed_systems ?? tokenClaims.allowedSystems);
|
||||
}
|
||||
|
||||
export function setActiveSystemCookie(cookies: Cookies, system: SystemType) {
|
||||
cookies.set('active_system', system, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveActiveCompanyId<T extends { id: number }>(
|
||||
cookies: Cookies,
|
||||
companies: T[]
|
||||
): number | null {
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
if (cookieCompanyId) {
|
||||
const cookieId = Number.parseInt(cookieCompanyId, 10);
|
||||
if (Number.isFinite(cookieId) && companies.some((c) => c.id === cookieId)) return cookieId;
|
||||
}
|
||||
return companies.length > 0 ? companies[0].id : null;
|
||||
}
|
||||
|
||||
/** Permisos RBAC por compañía (fallback / validación en set-active). */
|
||||
export async function fetchAllowedSystems(
|
||||
cookies: Cookies,
|
||||
fetch: typeof globalThis.fetch,
|
||||
companyId: number
|
||||
): Promise<SystemType[]> {
|
||||
const res = await authenticatedFetch(
|
||||
`v1/core/permissions/me?company_id=${companyId}`,
|
||||
{ method: 'GET' },
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { allowed_systems?: unknown };
|
||||
return parseSystemsArray(data.allowed_systems);
|
||||
}
|
||||
|
||||
export type SystemGateResult =
|
||||
| { action: 'redirect_workspace' }
|
||||
| { action: 'proceed'; activeSystem: SystemType };
|
||||
|
||||
/**
|
||||
* Gate obligatorio. Prioridad:
|
||||
* 1. requestedSystem del URL (Hub tiene autoridad — viene del relay firmado one-time)
|
||||
* 2. cookieSystem validado contra allowedSystems del JWT
|
||||
* 3. Primer sistema de allowedSystems del JWT
|
||||
* 4. redirect_workspace si nada resuelve
|
||||
*
|
||||
* requestedSystem se acepta incluso si el JWT no trae allowed_systems (Keycloak sin claim):
|
||||
* el backend valida RBAC en cada request de API.
|
||||
*/
|
||||
export function resolveSystemGate(params: {
|
||||
tokenClaims: Record<string, unknown> | null | undefined;
|
||||
cookieSystem?: string | null;
|
||||
requestedSystem?: string | null;
|
||||
}): SystemGateResult {
|
||||
const requestedSystem = params.requestedSystem;
|
||||
if (isValidSystem(requestedSystem)) {
|
||||
return { action: 'proceed', activeSystem: requestedSystem };
|
||||
}
|
||||
|
||||
const allowedSystems = extractAllowedSystemsFromToken(params.tokenClaims);
|
||||
|
||||
const cookieSystem = params.cookieSystem;
|
||||
if (isValidSystem(cookieSystem)) {
|
||||
return { action: 'proceed', activeSystem: cookieSystem };
|
||||
}
|
||||
|
||||
if (allowedSystems.length > 0) {
|
||||
return { action: 'proceed', activeSystem: allowedSystems[0] };
|
||||
}
|
||||
|
||||
return { action: 'redirect_workspace' };
|
||||
}
|
||||
|
||||
export function redirectToWorkspaceBase(): never {
|
||||
throw redirect(303, getWorkspaceBaseUrl());
|
||||
}
|
||||
70
frontend/src/lib/stores/system.svelte.ts
Normal file
70
frontend/src/lib/stores/system.svelte.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type SystemType = 'fixed_asset' | 'inventory';
|
||||
|
||||
export const SYSTEM_LABELS: Record<SystemType, { name: string; code: string }> = {
|
||||
fixed_asset: { name: 'Módulo de Activo Fijo', code: 'SCAF' },
|
||||
inventory: { name: 'Módulo Control de Inventarios', code: 'SCAII' },
|
||||
};
|
||||
|
||||
const VALID_SYSTEMS = new Set<string>(['fixed_asset', 'inventory']);
|
||||
|
||||
class SystemStore {
|
||||
_activeSystem = $state<SystemType | null>(null);
|
||||
_allowedSystems = $state<SystemType[]>([]);
|
||||
_switching = $state(false);
|
||||
|
||||
get activeSystem() {
|
||||
return this._activeSystem;
|
||||
}
|
||||
get allowedSystems() {
|
||||
return this._allowedSystems;
|
||||
}
|
||||
get canSwitch() {
|
||||
return this._allowedSystems.length > 1;
|
||||
}
|
||||
get switching() {
|
||||
return this._switching;
|
||||
}
|
||||
get activeLabel() {
|
||||
return this._activeSystem ? SYSTEM_LABELS[this._activeSystem] : null;
|
||||
}
|
||||
|
||||
initialize(allowedSystems: SystemType[], cookieValue: string | null) {
|
||||
this._allowedSystems = allowedSystems;
|
||||
if (cookieValue && VALID_SYSTEMS.has(cookieValue) && allowedSystems.includes(cookieValue as SystemType)) {
|
||||
this._activeSystem = cookieValue as SystemType;
|
||||
} else if (allowedSystems.length === 1) {
|
||||
this._activeSystem = allowedSystems[0];
|
||||
} else {
|
||||
this._activeSystem = null;
|
||||
}
|
||||
}
|
||||
|
||||
async setActiveSystem(system: SystemType): Promise<boolean> {
|
||||
if (!this._allowedSystems.includes(system) || this._switching) return false;
|
||||
this._switching = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/system/set-active', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
this._activeSystem = system;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
this._switching = false;
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._activeSystem = null;
|
||||
this._allowedSystems = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const systemStore = new SystemStore();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
extractAllowedSystemsFromToken,
|
||||
fetchAllowedSystems,
|
||||
isValidSystem,
|
||||
mergeTokenClaims,
|
||||
setActiveSystemCookie
|
||||
} from '$lib/server/system-gate';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { system } = await request.json();
|
||||
|
||||
if (!isValidSystem(system)) {
|
||||
return json({ error: 'Sistema inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const accessToken = getAccessTokenFromCookies(cookies);
|
||||
if (!accessToken) {
|
||||
return json({ error: 'No autenticado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenClaims = mergeTokenClaims(null, accessToken);
|
||||
let allowedSystems = extractAllowedSystemsFromToken(tokenClaims);
|
||||
|
||||
if (allowedSystems.length === 0) {
|
||||
const rawCompanyId = cookies.get('active_company_id');
|
||||
const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN;
|
||||
if (Number.isFinite(companyId)) {
|
||||
allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedSystems.includes(system)) {
|
||||
return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 });
|
||||
}
|
||||
|
||||
setActiveSystemCookie(cookies, system);
|
||||
|
||||
return json({ success: true, system });
|
||||
} catch {
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
|
||||
import { isValidSystem, setActiveSystemCookie } from '$lib/server/system-gate';
|
||||
|
||||
// Disable client-side rendering to prevent SvelteKit from making a second
|
||||
// __data.json request that would consume the one-time relay token twice.
|
||||
@@ -15,7 +16,8 @@ export const csr = false;
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const relayToken = url.searchParams.get('relay');
|
||||
console.log('[SSO] relay token presente:', !!relayToken);
|
||||
const requestedSystem = url.searchParams.get('active_system');
|
||||
console.log('[SSO] relay token presente:', !!relayToken, '| active_system:', requestedSystem ?? '(none)');
|
||||
|
||||
if (!relayToken) {
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
@@ -183,7 +185,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
}
|
||||
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
|
||||
console.log('[SSO] cookies configuradas, preparando redirect a /dashboard con active_system:', requestedSystem ?? '(none)');
|
||||
|
||||
// Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente.
|
||||
// Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO.
|
||||
@@ -202,5 +204,9 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
}).catch(() => {});
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
if (isValidSystem(requestedSystem)) {
|
||||
setActiveSystemCookie(cookies, requestedSystem);
|
||||
}
|
||||
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
import {
|
||||
redirectToWorkspaceLogin
|
||||
} from '$lib/server/workspace-auth';
|
||||
import {
|
||||
extractAllowedSystemsFromToken,
|
||||
mergeTokenClaims,
|
||||
resolveActiveCompanyId,
|
||||
resolveSystemGate,
|
||||
setActiveSystemCookie,
|
||||
redirectToWorkspaceBase
|
||||
} from '$lib/server/system-gate';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Verificar si existe el token en las cookies
|
||||
@@ -31,15 +39,22 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
|
||||
const userData = await validateAuth(cookies, fetch, redirectOnFail);
|
||||
|
||||
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
if (cookieCompanyId) {
|
||||
const cookieId = parseInt(cookieCompanyId);
|
||||
const stillExists = companies.some((c) => c.id === cookieId);
|
||||
if (!stillExists) {
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
}
|
||||
const tokenClaims = mergeTokenClaims(userData, accessToken);
|
||||
const gate = resolveSystemGate({
|
||||
tokenClaims,
|
||||
cookieSystem: cookies.get('active_system') ?? null,
|
||||
requestedSystem: url.searchParams.get('active_system') ?? null
|
||||
});
|
||||
const activeCompanyId = resolveActiveCompanyId(cookies, companies);
|
||||
const allowedSystemsFromToken = extractAllowedSystemsFromToken(tokenClaims);
|
||||
if (gate.action === 'redirect_workspace') {
|
||||
redirectToWorkspaceBase();
|
||||
}
|
||||
// Si el JWT no trae el claim allowed_systems pero el gate resolvió un sistema,
|
||||
// usar el sistema activo como mínimo para que el store pueda inicializarse.
|
||||
const allowedSystems =
|
||||
allowedSystemsFromToken.length > 0 ? allowedSystemsFromToken : [gate.activeSystem];
|
||||
setActiveSystemCookie(cookies, gate.activeSystem);
|
||||
|
||||
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
|
||||
let userTenants: { id: number; name: string; slug: string }[] = [];
|
||||
@@ -59,14 +74,13 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// No bloquear el dashboard si falla la carga de tenants
|
||||
}
|
||||
|
||||
// Obtener la compañía activa de la cookie para persistencia
|
||||
const activeCompanyId = cookies.get('active_company_id');
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: { ...userData, token: accessToken },
|
||||
user: { ...userData, token: accessToken, allowedSystems },
|
||||
companies,
|
||||
activeCompanyId: activeCompanyId ? parseInt(activeCompanyId) : undefined,
|
||||
activeCompanyId: activeCompanyId ?? undefined,
|
||||
activeSystem: gate.activeSystem,
|
||||
allowedSystems,
|
||||
userTenants,
|
||||
error: undefined
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import type { LayoutData } from './$types';
|
||||
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
|
||||
import AppLauncher from '$lib/components/sidebar/app-launcher.svelte';
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
@@ -21,6 +22,7 @@
|
||||
import { authStore, markPermissionsHydrated } from '$lib/auth';
|
||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
|
||||
import { systemStore, type SystemType } from '$lib/stores/system.svelte';
|
||||
|
||||
type LicenseError = { type: string; message: string; status: number };
|
||||
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
|
||||
@@ -90,17 +92,26 @@
|
||||
legacyAvatarUrl: data.user.legacyAvatarUrl ?? data.user.legacy_avatar_url ?? data.user.avatar_url ?? null,
|
||||
tenantId: data.user.tenant_id,
|
||||
roles: data.user.roles ?? [],
|
||||
permissions: data.user.permissions ?? []
|
||||
permissions: data.user.permissions ?? [],
|
||||
allowedSystems: data.allowedSystems ?? data.user.allowedSystems ?? []
|
||||
});
|
||||
}
|
||||
|
||||
syncAuthStoreFromData();
|
||||
systemStore.initialize(
|
||||
(data.allowedSystems ?? []) as SystemType[],
|
||||
(data.activeSystem ?? null) as string | null
|
||||
);
|
||||
|
||||
// Re-sincronizar si data.user cambia (por ejemplo, tras invalidateAll o cambio de compañía)
|
||||
$effect(() => {
|
||||
// Dependencia explícita para que el effect reaccione al swap de data.user
|
||||
data.user;
|
||||
syncAuthStoreFromData();
|
||||
systemStore.initialize(
|
||||
(data.allowedSystems ?? []) as SystemType[],
|
||||
(data.activeSystem ?? null) as string | null
|
||||
);
|
||||
});
|
||||
|
||||
// ── Manejar expiración de sesión ────────────────────────────────────────
|
||||
@@ -181,20 +192,12 @@
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<!--
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href="/dashboard">Dashboard</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>Inicio</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
-->
|
||||
</div>
|
||||
{#if systemStore.allowedSystems.length > 0}
|
||||
<div class="ml-auto px-4">
|
||||
<AppLauncher />
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
id="dashboard-main-content"
|
||||
|
||||
@@ -7,6 +7,8 @@ export const load: LayoutLoad = async ({ data }) => {
|
||||
companies: data.companies,
|
||||
authenticated: data.authenticated,
|
||||
userTenants: data.userTenants ?? [],
|
||||
activeCompanyId: data.activeCompanyId
|
||||
activeCompanyId: data.activeCompanyId,
|
||||
activeSystem: data.activeSystem ?? null,
|
||||
allowedSystems: data.allowedSystems ?? []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
|
||||
import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { systemStore } from '$lib/stores/system.svelte';
|
||||
import DataTable from '$lib/components/dashboard/goods/classes/data-table.svelte';
|
||||
import { columns } from '$lib/components/dashboard/goods/classes/columns';
|
||||
import { currentUser } from '$lib/auth';
|
||||
@@ -66,6 +67,9 @@
|
||||
let isSaving = $state(false);
|
||||
let sorting = $state<import("@tanstack/table-core").SortingState>([]);
|
||||
let status = $state<number>(200);
|
||||
const activeSystem = $derived(systemStore.activeSystem);
|
||||
const isInventoryMode = $derived(activeSystem === 'inventory');
|
||||
const isFixedAssetMode = $derived(activeSystem !== 'inventory');
|
||||
|
||||
// Permisos
|
||||
const canView = $derived(canViewGoodsClasses($currentUser));
|
||||
@@ -139,7 +143,7 @@
|
||||
const seq = ++loadSeq;
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await classesApi.getWithFAData({
|
||||
const commonParams = {
|
||||
company_id: companyId,
|
||||
page: currentPage,
|
||||
page_size: PAGE_SIZE,
|
||||
@@ -155,7 +159,10 @@
|
||||
material_key: debouncedFilters.material_key.trim()
|
||||
}),
|
||||
...(debouncedFilters.fraction.trim() && { fraction: debouncedFilters.fraction.trim() })
|
||||
});
|
||||
};
|
||||
const response = isInventoryMode
|
||||
? await classesApi.list(commonParams)
|
||||
: await classesApi.getWithFAData(commonParams);
|
||||
|
||||
if (seq !== loadSeq) return;
|
||||
|
||||
@@ -174,7 +181,9 @@
|
||||
} else if (error?.status) {
|
||||
status = error.status;
|
||||
}
|
||||
toast.error('Error al cargar las clases de activo fijo');
|
||||
toast.error(
|
||||
isInventoryMode ? 'Error al cargar las clases de inventario' : 'Error al cargar las clases de activo fijo'
|
||||
);
|
||||
} finally {
|
||||
if (seq === loadSeq) {
|
||||
isLoading = false;
|
||||
@@ -266,7 +275,11 @@
|
||||
|
||||
function handleRowDoubleClick(cls: A76Class) {
|
||||
if (!canEdit) {
|
||||
toast.error('No tienes permiso para editar clases de activo fijo');
|
||||
toast.error(
|
||||
isInventoryMode
|
||||
? 'No tienes permiso para editar clases de inventario'
|
||||
: 'No tienes permiso para editar clases de activo fijo'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fixedClass = cls as FixedAssetClassExtended;
|
||||
@@ -389,8 +402,14 @@
|
||||
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
|
||||
<div class="flex-none flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Clases de Activo Fijo</h1>
|
||||
<p class="text-muted-foreground">Gestiona y consulta las clases de activo fijo</p>
|
||||
<h1 class="text-2xl font-bold tracking-tight">
|
||||
{isInventoryMode ? 'Clases de Inventario' : 'Clases de Activo Fijo'}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{isInventoryMode
|
||||
? 'Gestiona y consulta las clases del sistema de inventario'
|
||||
: 'Gestiona y consulta las clases de activo fijo'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" class="h-9" onclick={handleRefresh} disabled={isLoading}>
|
||||
@@ -649,7 +668,9 @@
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="border-b p-6 pb-4">
|
||||
<Dialog.Title>{selectedClass ? 'Editar' : 'Nueva'} Clase de Activo Fijo</Dialog.Title>
|
||||
<Dialog.Title>
|
||||
{selectedClass ? 'Editar' : 'Nueva'} {isInventoryMode ? 'Clase de Inventario' : 'Clase de Activo Fijo'}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Mensaje de error de validación -->
|
||||
@@ -685,6 +706,7 @@
|
||||
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<FixedAssetClassForm
|
||||
mode={isInventoryMode ? 'inventory' : 'fixed_asset'}
|
||||
initialData={selectedClass}
|
||||
externalError={validationError}
|
||||
onClearError={() => (validationError = '')}
|
||||
@@ -736,50 +758,52 @@
|
||||
companyId
|
||||
);
|
||||
|
||||
// También actualizar la extensión FA
|
||||
if (selectedClass.fa_class_id) {
|
||||
await faClassesApi.update(
|
||||
selectedClass.fa_class_id,
|
||||
{
|
||||
depreciation_rate:
|
||||
cleanData.annual_depreciation_rate !== undefined &&
|
||||
cleanData.annual_depreciation_rate !== null &&
|
||||
cleanData.annual_depreciation_rate !== ''
|
||||
? Number(cleanData.annual_depreciation_rate)
|
||||
: cleanData.depreciation_rate !== undefined &&
|
||||
cleanData.depreciation_rate !== null &&
|
||||
cleanData.depreciation_rate != null
|
||||
? Number(cleanData.depreciation_rate)
|
||||
: null,
|
||||
import_tariff_code: mxFraccionNorm || null,
|
||||
import_tariff_type: cleanData.import_tariff_type || null,
|
||||
export_tariff_code: cleanData.export_tariff_code || null,
|
||||
export_tariff_type: cleanData.export_tariff_type || null,
|
||||
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
|
||||
eccn_code: cleanData.eccn_code || null
|
||||
},
|
||||
companyId
|
||||
);
|
||||
} else {
|
||||
await faClassesApi.create(
|
||||
{
|
||||
class_id: selectedClass.id,
|
||||
depreciation_rate:
|
||||
cleanData.annual_depreciation_rate !== undefined &&
|
||||
cleanData.annual_depreciation_rate !== null &&
|
||||
cleanData.annual_depreciation_rate !== ''
|
||||
? Number(cleanData.annual_depreciation_rate)
|
||||
: cleanData.depreciation_rate !== undefined &&
|
||||
cleanData.depreciation_rate !== null &&
|
||||
cleanData.depreciation_rate != null
|
||||
? Number(cleanData.depreciation_rate)
|
||||
: null,
|
||||
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
|
||||
eccn_code: cleanData.eccn_code || null,
|
||||
class_enabled: true
|
||||
},
|
||||
companyId
|
||||
);
|
||||
if (isFixedAssetMode) {
|
||||
// También actualizar la extensión FA cuando estamos en SCAF
|
||||
if (selectedClass.fa_class_id) {
|
||||
await faClassesApi.update(
|
||||
selectedClass.fa_class_id,
|
||||
{
|
||||
depreciation_rate:
|
||||
cleanData.annual_depreciation_rate !== undefined &&
|
||||
cleanData.annual_depreciation_rate !== null &&
|
||||
cleanData.annual_depreciation_rate !== ''
|
||||
? Number(cleanData.annual_depreciation_rate)
|
||||
: cleanData.depreciation_rate !== undefined &&
|
||||
cleanData.depreciation_rate !== null &&
|
||||
cleanData.depreciation_rate != null
|
||||
? Number(cleanData.depreciation_rate)
|
||||
: null,
|
||||
import_tariff_code: mxFraccionNorm || null,
|
||||
import_tariff_type: cleanData.import_tariff_type || null,
|
||||
export_tariff_code: cleanData.export_tariff_code || null,
|
||||
export_tariff_type: cleanData.export_tariff_type || null,
|
||||
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
|
||||
eccn_code: cleanData.eccn_code || null
|
||||
},
|
||||
companyId
|
||||
);
|
||||
} else {
|
||||
await faClassesApi.create(
|
||||
{
|
||||
class_id: selectedClass.id,
|
||||
depreciation_rate:
|
||||
cleanData.annual_depreciation_rate !== undefined &&
|
||||
cleanData.annual_depreciation_rate !== null &&
|
||||
cleanData.annual_depreciation_rate !== ''
|
||||
? Number(cleanData.annual_depreciation_rate)
|
||||
: cleanData.depreciation_rate !== undefined &&
|
||||
cleanData.depreciation_rate !== null &&
|
||||
cleanData.depreciation_rate != null
|
||||
? Number(cleanData.depreciation_rate)
|
||||
: null,
|
||||
fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null,
|
||||
eccn_code: cleanData.eccn_code || null,
|
||||
class_enabled: true
|
||||
},
|
||||
companyId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status }
|
||||
@@ -815,7 +839,9 @@
|
||||
class_enabled: true
|
||||
};
|
||||
|
||||
response = await classesApi.createFA(payload, companyId);
|
||||
response = isInventoryMode
|
||||
? await classesApi.create(payload, companyId)
|
||||
: await classesApi.createFA(payload, companyId);
|
||||
|
||||
if (response.error) {
|
||||
console.error('❌ Error del servidor:', response.error);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list';
|
||||
import { currentUser } from '$lib/auth';
|
||||
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
|
||||
import { systemStore, SYSTEM_LABELS } from '$lib/stores/system.svelte';
|
||||
import {
|
||||
canCreateGoodsParts,
|
||||
canDeleteGoodsParts,
|
||||
@@ -36,6 +37,9 @@
|
||||
let status = $state<number>(200);
|
||||
let showDeleteDialog = $state(false);
|
||||
|
||||
const activeSystem = $derived(systemStore.activeSystem);
|
||||
const activeSystemLabel = $derived(activeSystem ? SYSTEM_LABELS[activeSystem] : null);
|
||||
|
||||
// Permisos
|
||||
const canView = $derived(canViewGoodsParts($currentUser));
|
||||
const canCreate = $derived(canCreateGoodsParts($currentUser));
|
||||
@@ -187,7 +191,11 @@
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Catálogo de Partes</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Gestiona y consulta las partes de inventario y activo fijo
|
||||
{#if activeSystemLabel}
|
||||
{activeSystemLabel.name} ({activeSystemLabel.code})
|
||||
{:else}
|
||||
Gestiona y consulta las partes de inventario y activo fijo
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user