Merge pull request 'feature/data-entry' (#185) from feature/data-entry into development

Reviewed-on: ADUANASOFT/anexo76#185
This commit is contained in:
2026-03-05 15:36:42 +00:00
14 changed files with 890 additions and 735 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ wheels/
backend/.env
frontend/.env
backend/SCRIPTS/
# IDEs
.vscode/
.idea/

View File

@@ -73,9 +73,6 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import (
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.seed import (
seed as tariff_fractions_seed,
)
from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.seed import (
seed as historical_tariff_fractions_seed,
)
from api.v1.modules.public.reference_data.trailer_types.seed import (
seed as trailer_types_seed,
)
@@ -429,25 +426,7 @@ def upgrade() -> None:
f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;"
)
# TABLA MAESTRA UOM
# TODO: Generar tenant_id y company_id correctos
val_uom = ", ".join(
[
f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, "
f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)"
for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed
]
)
op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;")
op.execute(
f"""
INSERT INTO a76.units_of_measure
(code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id)
VALUES {val_uom}
ON CONFLICT (code, tenant_id, company_id) DO NOTHING;
"""
)
op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;")
# TABLA MAESTRA UOM se genera ahora al crear una empresa
# --- SEEDS CORE (Permissions) ---
@@ -501,45 +480,7 @@ def upgrade() -> None:
"""
)
def format_bool(val):
"""Convert boolean string to SQL boolean."""
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return "TRUE" if str(val).upper() == "TRUE" else "FALSE"
def format_timestamp(val):
"""Format timestamp for PostgreSQL."""
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
# El valor ya viene en formato 'YYYY-MM-DD HH:MM:SS'
return f"'{str(val)}'"
values_historical_fractions = ", ".join(
[
f"({format_value(historical_fraction)}, {format_value(nico)},{format_value(unit_measure)}, {format_value(country)}, "
f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, "
f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, "
f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, "
f"{format_bool(by_log)}, {format_timestamp(end_date)}, "
f"1, 1)" # tenant_id=1, company_id=1
for historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax, export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date in historical_tariff_fractions_seed
]
)
if values_historical_fractions:
op.execute("SET session_replication_role = replica;")
op.execute(
f"""
INSERT INTO a76.historical_tariff_fractions
(historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector,
import_tax_rate, export_tax_rate, publication_date, is_immex,
normal_temporality, services_temporality, certified_temporality, by_log, end_date,
tenant_id, company_id)
VALUES {values_historical_fractions}
ON CONFLICT DO NOTHING;
"""
)
op.execute("SET session_replication_role = DEFAULT;")
# Historical Fractions se generan ahora al crear una empresa
def downgrade() -> None:

View File

@@ -3,6 +3,7 @@ Capa de servicio para lógica de negocio de empresa
"""
import logging
from datetime import datetime
from typing import List, Optional, Tuple, Dict, Any
from fastapi import HTTPException
@@ -12,7 +13,10 @@ from sqlalchemy.orm import Session
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from ...audit_log.services.service import AuditService
from ..units_of_measure.seed import seed as units_of_measure_seed
from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed
from core.context import get_user_context
from sqlalchemy import text
logger = logging.getLogger(__name__)
@@ -34,7 +38,7 @@ class CompanyService:
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Company], int]:
"""Get all companies for a tenant with pagination"""
query = db.query(Company).filter(Company.tenant_id == tenant_id)
query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
# Apply filters if provided
if filters:
@@ -62,6 +66,7 @@ class CompanyService:
.filter(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None)
)
.first()
)
@@ -381,7 +386,10 @@ class CompanyService:
if addr_ind2:
self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id))
# 7. Commit
# 7. Seed company data (tenant/company dependent)
self._seed_company_data(self.db, tenant_id, db_company.id)
# 8. Commit
self.db.commit()
self.db.refresh(db_company)
@@ -584,17 +592,9 @@ class CompanyService:
# ----------------------
try:
# Cascading deletes are handled by relationship settings, but manual is safer here
if company.certification: db.delete(company.certification)
if company.prevalidator: db.delete(company.prevalidator)
if company.electronic_agent: db.delete(company.electronic_agent)
if company.ventanilla_unica: db.delete(company.ventanilla_unica)
if company.cfdi: db.delete(company.cfdi)
for cert in company.digital_certificates: db.delete(cert)
for addr in company.addresses: db.delete(addr)
company.deleted_at = datetime.utcnow()
db.flush()
db.delete(company)
db.commit()
# --- Audit Log ---
@@ -698,11 +698,68 @@ class CompanyService:
# Custom methods
def _seed_company_data(self, db: Session, tenant_id: int, company_id: int):
"""Seeds tenant/company dependent data for a new company"""
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val).replace(chr(39), chr(39)*2)}'"
# 1. Units of Measure
val_uom = ", ".join(
[
f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, "
f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, {tenant_id}, {company_id})"
for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed
]
)
db.execute(text("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;"))
db.execute(text(f"INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING;"))
db.execute(text("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;"))
# 2. Historical Tariff Fractions
def format_bool(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return "TRUE" if str(val).upper() == "TRUE" else "FALSE"
def format_timestamp(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"
return f"'{str(val)}'"
values_historical = ", ".join(
[
f"({format_value(historical_fraction)}, {format_value(nico)}, {format_value(unit_measure)}, {format_value(country)}, "
f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, "
f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, "
f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, "
f"{format_bool(by_log)}, {format_timestamp(end_date)}, "
f"{tenant_id}, {company_id})"
for (historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax,
export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date) in historical_tariff_fractions_seed
]
)
if values_historical:
db.execute(text("SET session_replication_role = replica;"))
db.execute(text(f"""
INSERT INTO a76.historical_tariff_fractions
(historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector,
import_tax_rate, export_tax_rate, publication_date, is_immex,
normal_temporality, services_temporality, certified_temporality, by_log, end_date,
tenant_id, company_id)
VALUES {values_historical}
ON CONFLICT DO NOTHING;
"""))
db.execute(text("SET session_replication_role = DEFAULT;"))
def get_companies_by_tenant(self, tenant_id: int) -> List[Company]:
"""Get all companies for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.order_by(Company.name)
.all()
)
@@ -711,7 +768,7 @@ class CompanyService:
"""Check if a company exists for a tenant"""
return (
self.db.query(Company)
.filter(Company.tenant_id == tenant_id)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.first()
is not None
)

View File

@@ -266,7 +266,6 @@ services:
max-size: "10m"
max-file: "3"
# celery
celery_worker:
build: ./backend

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import type { CustomsBroker } from "./columns.js";
import DetailsDialog from "./details-dialog.svelte";
import DeleteDialog from "./delete-dialog.svelte";
import { goto } from "$app/navigation";
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import type { CustomsBroker } from './columns.js';
import DetailsDialog from './details-dialog.svelte';
import DeleteDialog from './delete-dialog.svelte';
import { goto } from '$app/navigation';
let {
let {
broker,
onSuccess
}: {
}: {
broker: CustomsBroker;
onSuccess?: () => void;
} = $props();
@@ -32,10 +32,6 @@
showDetailsDialog = true;
}
function handleEdit() {
showEditDialog = true;
}
function handleDelete() {
showDeleteDialog = true;
}
@@ -43,33 +39,27 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 p-0"
>
<span class="sr-only">Abrir menú</span>
<EllipsisIcon class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisIcon class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={handleCopyKey}>
Copiar clave
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyKey}>Copiar clave</DropdownMenu.Item>
{#if broker.tax_id}
<DropdownMenu.Item onclick={handleCopyTaxId}>
Copiar RFC
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyTaxId}>Copiar RFC</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={handleViewDetails}>
Ver detalles
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => goto(`/dashboard/customs_brokers/edit/${broker.broker_key}`)}>
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => goto(`/dashboard/customs_brokers/edit/${broker.broker_key}`)}
>
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-destructive">

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore, currentUser } from '$lib/auth';
import * as Sheet from '$lib/components/ui/sheet';
import { Button } from '$lib/components/ui/button';
@@ -60,7 +59,8 @@
title: '',
content: '',
updated_at: '',
last_editor: ''
last_editor: '',
content_type: 'markdown'
}; // Mock for UI
}
@@ -100,8 +100,10 @@
}
}
onMount(() => {
loadArticles();
$effect(() => {
if (helpStore.isOpen && articles.length === 0 && !isLoading) {
loadArticles();
}
});
// Simple markdown renderer fallback if marked is not available

View File

@@ -53,6 +53,7 @@
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
role="document"
bind:this={modalRef}
role="presentation"
onkeydown={handleFocusTrap}
>
<div
@@ -83,16 +84,19 @@
<!-- Global Navigation -->
<div>
<h3
class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Global Navigation (Alt)
</h3>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Global Navigation shortcuts"
bind:this={globalList}
tabindex="0"
tabindex="-1"
role="region"
aria-label="Global Navigation Shortcuts"
onkeydown={(event) => handleArrowScroll(event, globalList)}
>
{#each Object.entries(GLOBAL_CONF) as [key, route]}
@@ -118,19 +122,22 @@
<!-- Local Actions -->
<div>
<h3
class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
class="mb-3 text-sm font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Active Actions (Alt+Shift)
</h3>
{#if localShortcuts.length === 0}
<p class="text-sm italic text-gray-400">No specific actions for this view.</p>
<p class="text-sm text-gray-400 italic">No specific actions for this view.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Active Actions shortcuts"
bind:this={localList}
tabindex="0"
tabindex="-1"
role="region"
aria-label="Local Action Shortcuts"
onkeydown={(event) => handleArrowScroll(event, localList)}
>
{#each localShortcuts as shortcut}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
let {
items,
items
}: {
items: {
title: string;
@@ -37,30 +37,30 @@
}
function handleTriggerEnter(title: string) {
if (sidebar.state !== "collapsed") return;
if (sidebar.state !== 'collapsed') return;
activeTitle = title;
}
function handleTriggerLeave(event: PointerEvent, title: string) {
if (sidebar.state !== "collapsed") return;
if (sidebar.state !== 'collapsed') return;
// Si nos movemos al contenido (o nos quedamos en el trigger), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
function handleContentEnter(title: string) {
if (sidebar.state !== "collapsed") return;
if (sidebar.state !== 'collapsed') return;
activeTitle = title;
}
function handleContentLeave(event: PointerEvent, title: string) {
if (sidebar.state !== "collapsed") return;
if (sidebar.state !== 'collapsed') return;
// Si nos movemos de vuelta al trigger (o dentro del contenido), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
@@ -81,23 +81,26 @@
<Sidebar.Menu>
{#each items as item (item.title)}
{#if item.items && item.items.length > 0}
{#if sidebar.state === "collapsed"}
{#if sidebar.state === 'collapsed'}
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
<Sidebar.MenuItem>
<DropdownMenu.Root
open={activeTitle === item.title}
onOpenChange={(v) => onOpenChange(v, item.title)}
modal={false}
>
<DropdownMenu.Trigger asChild>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<div
id={`trigger-${item.title}`}
class="relative z-30 w-full flex justify-center"
class="relative z-30 flex w-full justify-center"
onpointerenter={() => handleTriggerEnter(item.title)}
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
>
<Sidebar.MenuButton {...props} tooltipContent={null} class="justify-center">
<Sidebar.MenuButton
{...props}
tooltipContent={undefined}
class="justify-center"
>
{#if item.icon}
<item.icon />
{:else}
@@ -113,7 +116,7 @@
side="right"
align="start"
sideOffset={0}
class="z-50 w-64 rounded-lg p-0 shadow-lg overflow-visible"
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
id={`content-${item.title}`}
onpointerenter={() => handleContentEnter(item.title)}
onpointerleave={(e) => handleContentLeave(e, item.title)}
@@ -123,7 +126,9 @@
Posicionado con right-full para estar exactamente donde el trigger termina (offset 0).
Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar.
-->
<div class="absolute right-full top-0 flex items-center justify-center bg-sidebar-accent text-sidebar-accent-foreground rounded-l-lg w-8 h-8 shadow-none border border-sidebar-border border-r-0 z-50">
<div
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
>
{#if item.icon}
<item.icon class="size-4 shrink-0" />
{:else}
@@ -134,20 +139,29 @@
<!--
Panel Principal
-->
<div class="w-full h-full p-1 pointer-events-auto bg-popover rounded-lg rounded-tl-none border border-sidebar-border ml-[0px]">
<div
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
>
<!-- Título en el panel principal -->
<div class="px-2 py-2 text-sm font-medium border-b text-sidebar-foreground truncate">
<div
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
>
{item.title}
</div>
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
{#each item.items as subItem (subItem.title)}
<DropdownMenu.DropdownMenuItem asChild>
<a href={subItem.url} class="flex w-full items-center gap-2 overflow-hidden">
<span class="truncate">{subItem.title}</span>
</a>
</DropdownMenu.DropdownMenuItem>
<DropdownMenu.Item>
{#snippet child({ props })}
<a
{...props}
href={subItem.url}
class="flex w-full items-center gap-2 overflow-hidden"
>
<span class="truncate">{subItem.title}</span>
</a>
{/snippet}
</DropdownMenu.Item>
{/each}
</DropdownMenu.DropdownMenuGroup>
</div>
@@ -209,4 +223,3 @@
{/each}
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -1,30 +1,30 @@
<script lang="ts">
import * as Avatar from "$lib/components/ui/avatar/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import BadgeCheckIcon from "@lucide/svelte/icons/badge-check";
import BellIcon from "@lucide/svelte/icons/bell";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import LanguagesIcon from "@lucide/svelte/icons/languages";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { logout } from "$lib/auth";
import { cookieName } from "$lib/paraglide/runtime";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
import { getBackendAssetUrl } from "$lib/utils";
import AppVersion from "$lib/components/app-version.svelte";
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import BellIcon from '@lucide/svelte/icons/bell';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import CreditCardIcon from '@lucide/svelte/icons/credit-card';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import LanguagesIcon from '@lucide/svelte/icons/languages';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { logout } from '$lib/auth';
import { cookieName } from '$lib/paraglide/runtime';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getBackendAssetUrl } from '$lib/utils';
import AppVersion from '$lib/components/app-version.svelte';
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
const sidebar = useSidebar();
// URL completa del avatar
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
// Iniciales del usuario (2 primeras letras)
let initials = $derived(user.name.slice(0, 2).toUpperCase());
@@ -65,34 +65,34 @@
function toggleLanguage() {
if (!browser) return;
// Leer la cookie actual para obtener el idioma real
const cookies = document.cookie.split(';').map(c => c.trim());
const localeCookie = cookies.find(c => c.startsWith(`${cookieName}=`));
const cookies = document.cookie.split(';').map((c) => c.trim());
const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`));
const current = localeCookie ? localeCookie.split('=')[1] : 'en';
// Alternar el idioma
const newLocale = current === 'en' ? 'es' : 'en';
// Establecer la cookie del idioma
document.cookie = `${cookieName}=${newLocale}; path=/; max-age=34560000; SameSite=Lax`;
// Recargar la página para que el servidor procese el nuevo idioma
window.location.reload();
}
function toggleTheme() {
if (!browser) return;
const html = document.documentElement;
const newTheme = html.classList.contains('dark') ? 'light' : 'dark';
if (newTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Guardar la preferencia en localStorage
localStorage.setItem('theme', newTheme);
isDarkMode = newTheme === 'dark';
@@ -123,7 +123,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import BuildingIcon from "@lucide/svelte/icons/building";
import CheckIcon from "@lucide/svelte/icons/check";
import { companyStore } from "$lib/stores/company.svelte";
import { getBackendAssetUrl } from "$lib/utils";
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import BuildingIcon from '@lucide/svelte/icons/building';
import CheckIcon from '@lucide/svelte/icons/check';
import { companyStore } from '$lib/stores/company.svelte';
import { getBackendAssetUrl } from '$lib/utils';
const sidebar = useSidebar();
@@ -14,95 +14,93 @@
let activeCompanyLogoUrl = $derived(
companyStore.activeCompany?.logo
? getBackendAssetUrl(
`v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${Date.now()}`
)
`v1/a76/company/${companyStore.activeCompany.id}/logo/image?t=${Date.now()}`
)
: null
);
// Iniciales de la compañía activa (2 primeras letras)
let activeCompanyInitials = $derived(
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || "CO"
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
);
// Fallback en degradé cuando no hay logo cargado
const fallbackBg =
"radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))";
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
const logoBg = $derived(
activeCompanyLogoUrl
? `url(${activeCompanyLogoUrl})`
: fallbackBg
);
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="relative overflow-hidden data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-has-data-[state=collapsed]/sidebar-wrapper:aspect-square group-has-data-[state=collapsed]/sidebar-wrapper:h-10 group-has-data-[state=collapsed]/sidebar-wrapper:w-10 group-has-data-[state=collapsed]/sidebar-wrapper:rounded-lg group-has-data-[state=collapsed]/sidebar-wrapper:justify-center group-has-data-[state=collapsed]/sidebar-wrapper:gap-0"
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="relative overflow-hidden group-has-data-[state=collapsed]/sidebar-wrapper:aspect-square group-has-data-[state=collapsed]/sidebar-wrapper:h-10 group-has-data-[state=collapsed]/sidebar-wrapper:w-10 group-has-data-[state=collapsed]/sidebar-wrapper:justify-center group-has-data-[state=collapsed]/sidebar-wrapper:gap-0 group-has-data-[state=collapsed]/sidebar-wrapper:rounded-lg data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg overflow-hidden group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || "Company"}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
{:else}
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
{/if}
</div>
<div class="grid flex-1 text-left text-sm leading-tight min-w-0 group-has-data-[state=collapsed]/sidebar-wrapper:hidden">
<span class="truncate font-medium">
{companyStore.activeCompany?.name || "Seleccionar compañía"}
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
{/if}
</div>
<div
class="grid min-w-0 flex-1 text-left text-sm leading-tight group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
>
<span class="truncate font-medium">
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
</span>
{#if companyStore.activeCompany?.rfc}
<span class="truncate text-xs text-muted-foreground">
{companyStore.activeCompany.rfc}
</span>
{#if companyStore.activeCompany?.rfc}
<span class="truncate text-xs text-muted-foreground">
{companyStore.activeCompany.rfc}
</span>
{/if}
</div>
<ChevronsUpDownIcon class="ml-auto size-4 group-has-data-[state=collapsed]/sidebar-wrapper:hidden" />
{/if}
</div>
<ChevronsUpDownIcon
class="ml-auto size-4 group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
/>
<!-- Isotipo compacto visible solo en modo colapsado -->
<div
class="relative z-10 hidden size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-foreground text-sm font-semibold shadow-sm ring-1 ring-sidebar-border/40 group-has-data-[state=collapsed]/sidebar-wrapper:flex overflow-hidden"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || "Company"}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
{:else}
{activeCompanyInitials}
{/if}
</div>
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<!-- Isotipo compacto visible solo en modo colapsado -->
<div
class="relative z-10 hidden size-8 items-center justify-center overflow-hidden rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-foreground shadow-sm ring-1 ring-sidebar-border/40 group-has-data-[state=collapsed]/sidebar-wrapper:flex"
>
{#if activeCompanyLogoUrl}
<img
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
{activeCompanyInitials}
{/if}
</div>
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? "bottom" : "right"}
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs">
Mis Compañías
</DropdownMenu.Label>
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
{#if companyStore.loading}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Cargando...</span>
@@ -113,25 +111,28 @@
</DropdownMenu.Item>
{:else}
{#each companyStore.companies as company, index (company.id)}
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="gap-2 p-2 cursor-pointer"
<DropdownMenu.Item
onSelect={() => companyStore.setActiveCompany(company)}
class="cursor-pointer gap-2 p-2"
>
<div class="flex size-6 items-center justify-center rounded-md border overflow-hidden">
<div
class="flex size-6 items-center justify-center overflow-hidden rounded-md border"
>
{#if company.logo}
<img
src={getBackendAssetUrl(`v1/a76/company/${company.id}/logo/image`)}
<img
src={getBackendAssetUrl(`v1/a76/company/${company.id}/logo/image`)}
alt={company.name}
class="size-full rounded object-cover"
/>
{:else}
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span
>
{/if}
</div>
<div class="flex flex-1 flex-col min-w-0">
<span class="font-medium truncate">{company.name}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium">{company.name}</span>
{#if company.rfc}
<span class="text-xs text-muted-foreground truncate">{company.rfc}</span>
<span class="truncate text-xs text-muted-foreground">{company.rfc}</span>
{/if}
</div>
{#if companyStore.activeCompany?.id === company.id}

View File

@@ -1,47 +1,45 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
import { tv, type VariantProps } from 'tailwind-variants';
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn, type WithElementRef, type WithoutChildrenOrChild } from '$lib/utils.js';
import { mergeProps } from 'bits-ui';
import type { ComponentProps, Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
isActive = false,
tooltipContent,
tooltipContentProps,
@@ -59,11 +57,11 @@
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
'data-slot': 'sidebar-menu-button',
'data-sidebar': 'menu-button',
'data-size': size,
'data-active': isActive,
...restProps
});
</script>
@@ -90,10 +88,10 @@
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{#if typeof tooltipContent === 'string'}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
side = 'top',
children,
arrowClasses,
...restProps
@@ -22,7 +22,7 @@
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground',
className
)}
{...restProps}
@@ -32,11 +32,11 @@
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}

File diff suppressed because it is too large Load Diff

View File

@@ -137,6 +137,7 @@ POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_DB="${POSTGRES_DB:-anexo76_core}"
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}"
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
DEMO_USERNAME="demo"
DEMO_PASSWORD="demo123"
@@ -601,27 +602,6 @@ fi
echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}"
# Insertar company si no existe
COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
if [ "$COMPANY_EXISTS" = "0" ]; then
exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
exec_pg_sql_client "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
echo -e "${GREEN}✓ Company creada (Hub y Cliente)${NC}"
else
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
fi
# Obtener ID de la company (necesario para user_tenants)
COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs)
if [ -z "$COMPANY_ID" ]; then
echo -e "${RED}✗ Error: No se pudo obtener el ID de la company${NC}"
exit 1
fi
COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;")
echo -e "${GREEN}✓ Company ID: ${COMPANY_ID} | ${COMPANY_INFO}${NC}"
# Agregar tenant_id al usuario demo en Keycloak
echo -e "\n${YELLOW}Asignando tenant_id al usuario demo...${NC}"
@@ -636,14 +616,71 @@ curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}"
echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}"
# Agregar relación usuario-tenant en la base de datos (usar company_id real)
# Obtener token de demo user para usar la API con su tenant
echo "Obteniendo token de usuario demo..."
DEMO_TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${DEMO_USERNAME}" \
-d "password=${DEMO_PASSWORD}" \
-d "grant_type=password" \
-d "client_id=anexo76-backend" \
-d "client_secret=${BACKEND_SECRET}")
DEMO_ACCESS_TOKEN=$(echo "$DEMO_TOKEN_RESPONSE" | jq -r '.access_token // empty')
if [ -z "$DEMO_ACCESS_TOKEN" ]; then
echo -e "${RED}✗ Error: No se pudo obtener el token de usuario demo${NC}"
exit 1
fi
# Insertar company si no existe
COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
if [ "$COMPANY_EXISTS" = "0" ]; then
echo " → Creando company mediante API..."
CREATE_COMPANY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${BACKEND_URL}/api/v1/a76/company" \
-H "Authorization: Bearer ${DEMO_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"${COMPANY_NAME}\",
\"rfc\": \"${COMPANY_RFC}\",
\"is_service_company\": false
}")
HTTP_CODE=$(echo "$CREATE_COMPANY_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$CREATE_COMPANY_RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "201" ]; then
COMPANY_ID=$(echo "$RESPONSE_BODY" | jq -r '.id // empty')
echo -e "${GREEN}✓ Company creada exitosamente via API (Hub, Cliente replicado por DB trigger o servicio asíncrono)${NC}"
else
echo -e "${RED}✗ Error al crear company via API (HTTP ${HTTP_CODE})${NC}"
echo "Respuesta: $RESPONSE_BODY"
exit 1
fi
else
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
# Obtener información de la company existente
COMPANY_ID=$(exec_pg_sql "SELECT id FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;" | xargs)
fi
COMPANY_INFO=$(exec_pg_sql "SELECT name FROM a76.company WHERE id = ${COMPANY_ID} AND tenant_id = ${TENANT_ID} LIMIT 1;")
echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}"
echo -e "${GREEN}✓ Company ID: ${COMPANY_ID}${NC}"
# Agregar relación usuario-tenant en la base de datos
echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}"
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos (Hub y Cliente)${NC}"
###############################################################################
# 9. Crear licencia Enterprise para el tenant
###############################################################################