feature/app-selector

This commit is contained in:
2026-05-26 16:59:32 -06:00
parent 904fa965c8
commit dbf68986da
34 changed files with 792 additions and 169 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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(

View File

@@ -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]:
"""

View File

@@ -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;

View File

@@ -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()
});
}

View File

@@ -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>

View File

@@ -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,

View 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>

View File

@@ -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";

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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,

View 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());
}

View 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();

View File

@@ -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 });
}
};

View File

@@ -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');
};

View File

@@ -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
};

View File

@@ -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"

View File

@@ -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 ?? []
};
};

View File

@@ -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);

View File

@@ -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">