Files
CRM_AGENTES_CARGA/frontend/src/lib/stores/company.svelte.ts
Ernesto Herrera 63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00

260 lines
8.0 KiB
TypeScript

/**
* Store para manejar la compañía activa del usuario
* Permite cambiar entre las compañías que pertenecen al tenant
*/
import { browser } from '$app/environment';
interface Company {
id: number;
name: string;
rfc?: string;
logo?: string;
tenant_id: number;
}
class CompanyStore {
private _activeCompany = $state<Company | null>(null);
private _companies = $state<Company[]>([]);
private _loading = $state(false);
private _currentTenantId = $state<number | null>(null);
get activeCompany() {
return this._activeCompany;
}
get companies() {
return this._companies;
}
get loading() {
return this._loading;
}
/**
* Carga las compañías del tenant del usuario desde el backend
* @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR)
*/
async loadCompanies(preloadedCompanies?: Company[], activeCompanyId?: number) {
// Si tenemos compañías pre-cargadas, usarlas directamente
if (preloadedCompanies && preloadedCompanies.length > 0) {
// Detectar si el tenant ha cambiado
const newTenantId = preloadedCompanies[0].tenant_id;
// Si el tenant cambió, limpiar el store primero
if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) {
this.clear();
}
this._currentTenantId = newTenantId;
this._companies = preloadedCompanies;
// Si hay compañías y no hay una activa, seleccionar la correcta.
// IMPORTANTE: `setActiveCompany` es async (dispara syncCompanyPermissions);
// hay que `await` para que el caller (initialize → markPermissionsHydrated)
// no marque la hidratación antes de que los permisos del backend lleguen.
if (this._companies.length > 0 && !this._activeCompany) {
// Prioridad 1: ID pasado por parámetro (desde SSR/Cookie)
if (activeCompanyId) {
const company = this._companies.find(c => c.id === activeCompanyId);
if (company) {
await this.setActiveCompany(company, true);
return;
}
}
// Prioridad 2: Intentar restaurar de localStorage (Browser only)
if (typeof window !== 'undefined') {
const savedId = localStorage.getItem('activeCompanyId');
if (savedId) {
const company = this._companies.find(c => c.id === parseInt(savedId));
if (company) {
await this.setActiveCompany(company, true); // silent=true para inicialización
return;
}
}
}
// Prioridad 3: Si no hay nada, seleccionar la primera
await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
}
return;
}
// Si no hay datos pre-cargados, hacer fetch (fallback)
// Solo en el navegador, nunca durante SSR
if (!browser) {
return;
}
this._loading = true;
try {
// Importamos dinámicamente para evitar dependencias circulares si las hubiera
const { api } = await import('$lib/api');
const response = await api.get<Company[]>('/v1/auth/my-companies');
if (response.data) {
const newCompanies = response.data;
// Detectar si el tenant ha cambiado
if (newCompanies.length > 0) {
const newTenantId = newCompanies[0].tenant_id;
// Si el tenant cambió, limpiar el store primero
if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) {
this.clear();
}
this._currentTenantId = newTenantId;
}
this._companies = newCompanies;
// Si hay compañías y no hay una activa, seleccionar la primera.
// `await` necesario para esperar a syncCompanyPermissions antes de
// que la inicialización del layout marque permissionsHydrated.
if (this._companies.length > 0 && !this._activeCompany) {
await this.setActiveCompany(this._companies[0], true); // silent=true para inicialización
}
} else {
console.error('Error loading companies:', response.error);
if (response.status === 402) {
const { toast } = await import('svelte-sonner');
toast.error('Licencia inactiva', {
duration: 8000,
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
});
this.clear();
} else if (response.status === 401) {
this.clear();
}
}
} catch (error) {
console.error('Error loading companies:', error);
} finally {
this._loading = false;
}
}
/**
* Establece la compañía activa
* @param company - La compañía a establecer como activa
* @param silent - Si es true, no dispara el evento companyChanged (para inicialización)
*/
async setActiveCompany(company: Company, silent: boolean = false) {
const previousCompanyId = this._activeCompany?.id;
this._activeCompany = company;
// Guardar en localStorage para persistencia
if (typeof window !== 'undefined') {
localStorage.setItem('activeCompanyId', company.id.toString());
}
// Guardar en cookie para acceso desde el servidor (SSR)
// Usar el endpoint del servidor para garantizar que la cookie esté disponible en SSR
if (browser) {
try {
await fetch('/api-sveltekit/company/set-active', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// tenant_id de la compañía → override para que el backend escale por
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
credentials: 'include'
});
} catch (error) {
console.error('Error setting active company cookie:', error);
}
try {
const { syncCompanyPermissions } = await import('$lib/auth');
await syncCompanyPermissions(company.id);
} catch (e) {
console.warn('syncCompanyPermissions:', e);
}
}
// Despachar evento personalizado solo si:
// 1. No es silent (no es inicialización)
// 2. Y realmente cambió la compañía (el ID es diferente)
if (!silent && typeof window !== 'undefined' && previousCompanyId !== company.id) {
window.dispatchEvent(new CustomEvent('companyChanged', {
detail: { companyId: company.id }
}));
}
}
/**
* Actualiza los datos de une empresa en el store localmente
* Útil para reflejar cambios inmediatos (ej: cambio de logo) sin recargar
*/
updateCompany(id: number, data: Partial<Company>) {
// 1. Actualizar en la lista
const index = this._companies.findIndex(c => c.id === id);
if (index !== -1) {
this._companies[index] = { ...this._companies[index], ...data };
// 2. Si es la activa, actualizar también
if (this._activeCompany?.id === id) {
this._activeCompany = { ...this._activeCompany, ...data };
// Actualizar persistencia si es necesario
if (typeof window !== 'undefined') {
// Disparar evento para notificar cambios a componentes que no usan el store reactivo directo (si los hay)
window.dispatchEvent(new CustomEvent('companyChanged', {
detail: { companyId: id }
}));
}
}
}
}
/**
* Restaura la compañía activa desde localStorage
*/
restoreActiveCompany() {
if (typeof window !== 'undefined') {
const savedId = localStorage.getItem('activeCompanyId');
if (savedId && this._companies.length > 0) {
const company = this._companies.find(c => c.id === parseInt(savedId));
if (company) {
this._activeCompany = company;
}
}
}
}
/**
* Limpia el store (útil al cambiar de tenant o cerrar sesión)
*/
clear() {
this._activeCompany = null;
this._companies = [];
this._loading = false;
this._currentTenantId = null;
// Limpiar localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem('activeCompanyId');
}
// Limpiar cookie
if (typeof document !== 'undefined') {
document.cookie = 'active_company_id=; path=/; max-age=0';
}
}
/**
* Inicializa el store cargando las compañías
* @param preloadedCompanies - Compañías pre-cargadas desde el servidor (SSR)
*/
async initialize(preloadedCompanies?: Company[], activeCompanyId?: number) {
await this.loadCompanies(preloadedCompanies, activeCompanyId);
// Si no hay compañías pre-cargadas, intentar restaurar de localStorage
if (!preloadedCompanies) {
this.restoreActiveCompany();
}
}
}
export const companyStore = new CompanyStore();