feature/app-selector
This commit is contained in:
@@ -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