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>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
Ship,
|
||||
Receipt,
|
||||
Building2,
|
||||
Building,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
@@ -70,6 +71,11 @@ export function getNavMain(): NavMainItem[] {
|
||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Compañías',
|
||||
url: '/dashboard/companias',
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
url: '/dashboard/workspace/organizaciones',
|
||||
|
||||
@@ -158,7 +158,9 @@ class CompanyStore {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
// 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) {
|
||||
|
||||
@@ -6,22 +6,28 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { companyId } = await request.json();
|
||||
const body = await request.json();
|
||||
const companyId = body?.companyId;
|
||||
const tenantId = body?.tenantId;
|
||||
|
||||
if (!companyId || typeof companyId !== 'number') {
|
||||
return json({ error: 'Invalid company ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Establecer la cookie desde el servidor
|
||||
cookies.set('active_company_id', companyId.toString(), {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // Permitir acceso desde JavaScript
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
|
||||
|
||||
return json({ success: true, companyId });
|
||||
// Compañía activa (legible desde JS)
|
||||
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
|
||||
|
||||
// Fijar el tenant de la compañía como override → el backend escala por ese
|
||||
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
|
||||
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
|
||||
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
|
||||
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
|
||||
}
|
||||
|
||||
return json({ success: true, companyId, tenantId: tenantId ?? null });
|
||||
} catch (error) {
|
||||
console.error('Error setting active company:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal file
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Building, Plus } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { api } from '$lib/api';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
type Tenant = { id: number; name: string; slug: string };
|
||||
|
||||
let tenants = $state<Tenant[]>([]);
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let name = $state('');
|
||||
let rfc = $state('');
|
||||
let tenantId = $state<number | null>(null);
|
||||
|
||||
const companies = $derived(companyStore.companies);
|
||||
|
||||
onMount(async () => {
|
||||
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
|
||||
if (res.data) {
|
||||
tenants = res.data;
|
||||
if (tenants.length === 1) tenantId = tenants[0].id;
|
||||
}
|
||||
await companyStore.loadCompanies();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function createCompany() {
|
||||
if (name.trim().length < 2) {
|
||||
toast.error('El nombre de la compañía es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!tenantId) {
|
||||
toast.error('Selecciona el tenant al que pertenece');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await api.post<{ id: number }>('/v1/auth/companies', {
|
||||
name: name.trim(),
|
||||
tenant_id: tenantId,
|
||||
rfc: rfc.trim() || null
|
||||
});
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Compañía creada');
|
||||
name = '';
|
||||
rfc = '';
|
||||
await companyStore.loadCompanies();
|
||||
// Seleccionarla como activa para poder trabajar de inmediato.
|
||||
const created = res.data?.id
|
||||
? companyStore.companies.find((c) => c.id === res.data!.id)
|
||||
: null;
|
||||
if (created) await companyStore.setActiveCompany(created);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building class="h-6 w-6" /> Compañías
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
|
||||
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
|
||||
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Nombre / Razón social *</span>
|
||||
<input class={inputCls} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">RFC</span>
|
||||
<input class="font-mono {inputCls}" maxlength="13" bind:value={rfc} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tenant (Workspace) *</span>
|
||||
<select class={inputCls} bind:value={tenantId}>
|
||||
<option value={null} disabled>Selecciona…</option>
|
||||
{#each tenants as t (t.id)}
|
||||
<option value={t.id}>{t.name} ({t.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button onclick={createCompany} disabled={submitting}>
|
||||
{submitting ? 'Creando…' : 'Crear compañía'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Compañías ({companies.length})</Card.Title>
|
||||
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if companies.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Nombre</th>
|
||||
<th class="py-2 pr-4 font-medium">RFC</th>
|
||||
<th class="py-2 pr-4 font-medium">Tenant</th>
|
||||
<th class="py-2 pr-4 font-medium">Activa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each companies as c (c.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{c.name}</td>
|
||||
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
|
||||
<td class="py-2 pr-4">{c.tenant_id}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if companyStore.activeCompany?.id === c.id}
|
||||
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
|
||||
{:else}
|
||||
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
Reference in New Issue
Block a user