Improve enterprise tier child tenants access

This commit is contained in:
Urtzi Alfaro
2026-01-07 16:01:19 +01:00
parent 2c1fc756a1
commit 560c7ba86f
19 changed files with 854 additions and 15 deletions

View File

@@ -360,7 +360,11 @@ class ApiClient {
}
setTenantId(tenantId: string | null) {
console.log('🔧 [API Client] setTenantId called with:', tenantId);
console.log('🔧 [API Client] Previous tenantId was:', this.tenantId);
console.trace('📍 [API Client] setTenantId call stack:');
this.tenantId = tenantId;
console.log('✅ [API Client] tenantId is now:', this.tenantId);
}
setDemoSessionId(sessionId: string | null) {

View File

@@ -0,0 +1,79 @@
/**
* Hook for premises (child tenants) management
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { tenantService } from '../services/tenant';
import type { TenantResponse } from '../types/tenant';
export interface PremisesFilters {
search?: string;
status?: 'active' | 'inactive' | '';
}
export interface PremisesStats {
total: number;
active: number;
inactive: number;
}
/**
* Get all child tenants (premises) for a parent tenant
*/
export const usePremises = (
parentTenantId: string,
filters?: PremisesFilters,
options?: { enabled?: boolean }
): UseQueryResult<TenantResponse[]> => {
return useQuery({
queryKey: ['premises', parentTenantId, filters],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
let filtered = response;
// Apply search filter
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(tenant =>
tenant.name.toLowerCase().includes(searchLower) ||
tenant.city?.toLowerCase().includes(searchLower)
);
}
// Apply status filter
if (filters?.status === 'active') {
filtered = filtered.filter(tenant => tenant.is_active);
} else if (filters?.status === 'inactive') {
filtered = filtered.filter(tenant => !tenant.is_active);
}
return filtered;
},
staleTime: 60000, // 1 min cache
enabled: (options?.enabled ?? true) && !!parentTenantId,
});
};
/**
* Get premises statistics
*/
export const usePremisesStats = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<PremisesStats> => {
return useQuery({
queryKey: ['premises', 'stats', parentTenantId],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
return {
total: response.length,
active: response.filter(t => t.is_active).length,
inactive: response.filter(t => !t.is_active).length,
};
},
staleTime: 60000,
enabled: (options?.enabled ?? true) && !!parentTenantId,
});
};

View File

@@ -1,7 +1,9 @@
import React, { useState, useCallback, forwardRef } from 'react';
import React, { useState, useCallback, forwardRef, useEffect } from 'react';
import { clsx } from 'clsx';
import { useLocation } from 'react-router-dom';
import { useTheme } from '../../../contexts/ThemeContext';
import { useHasAccess } from '../../../hooks/useAccessControl';
import { useTenant } from '../../../stores/tenant.store';
import { Header } from '../Header';
import { Sidebar } from '../Sidebar';
import { Footer } from '../Footer';
@@ -77,10 +79,29 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
const authLoading = false; // Since we're in a protected route, auth loading should be false
const { resolvedTheme } = useTheme();
const hasAccess = useHasAccess(); // Check both authentication and demo mode
const location = useLocation();
const { parentTenant, restoreParentTenant } = useTenant();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
const [error, setError] = useState<Error | null>(null);
const [isRestoringTenant, setIsRestoringTenant] = useState(false);
// Auto-restore parent tenant context when navigating away from premises detail view
useEffect(() => {
// If we have a parent tenant stored (meaning we switched to a child premise)
// and we're NOT on the premises page, restore the parent tenant
if (parentTenant && !location.pathname.includes('/enterprise/premises')) {
console.log('[AppShell] Restoring parent tenant context - navigated away from premises');
setIsRestoringTenant(true);
restoreParentTenant().finally(() => {
// Small delay to ensure all components have the updated tenant context
setTimeout(() => {
setIsRestoringTenant(false);
}, 100);
});
}
}, [location.pathname, parentTenant, restoreParentTenant]);
// Sidebar control functions
const toggleSidebar = useCallback(() => {
@@ -188,6 +209,24 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
);
}
// Show loading state during tenant restoration to prevent race condition
// CRITICAL: Also check if parentTenant exists and we're NOT on premises page
// This catches the case where navigation happens before the useEffect runs
const isNavigatingAwayFromChild = parentTenant && !location.pathname.includes('/enterprise/premises');
if (isRestoringTenant || isNavigatingAwayFromChild) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
{loadingComponent || (
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)]">Restaurando contexto...</p>
</div>
)}
</div>
);
}
// Show error state
if (error && ErrorBoundary) {
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;

View File

@@ -50,7 +50,8 @@ import {
Layers,
Lightbulb,
Activity,
List
List,
Building
} from 'lucide-react';
export interface SidebarProps {
@@ -138,6 +139,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
events: Activity,
list: List,
distribution: Truck,
premises: Building,
};
/**
@@ -196,6 +198,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const pathMappings: Record<string, string> = {
'/app/dashboard': 'navigation.dashboard',
'/app/enterprise/premises': 'navigation.premises',
'/app/operations': 'navigation.operations',
'/app/operations/procurement': 'navigation.procurement',
'/app/operations/production': 'navigation.production',

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useTenant } from '../../stores/tenant.store';
import { useSubscription } from '../../api/hooks/subscription';
import { showToast } from '../../utils/toast';
import { ChevronDown, Building2, Check, AlertCircle, Plus, X } from 'lucide-react';
@@ -36,6 +37,8 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
clearError,
} = useTenant();
const { subscriptionInfo } = useSubscription();
// NOTE: Removed duplicate loadUserTenants() useEffect
// Tenant loading is already handled by useTenantInitializer at app level (stores/useTenantInitializer.ts)
// This was causing duplicate /tenants API calls on every dashboard load
@@ -167,6 +170,11 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
navigate('/app/onboarding?new=true');
};
// Don't render for enterprise tier users (they use Premises page instead)
if (subscriptionInfo?.plan === 'enterprise') {
return null;
}
// Don't render if no tenants available
if (!availableTenants || availableTenants.length === 0) {
return null;

View File

@@ -11,6 +11,7 @@
"pos": "Point of Sale",
"distribution": "Distribution",
"central_baker": "Central Baker",
"premises": "Premises",
"analytics": "Analytics",
"production_analytics": "Production Dashboard",
"procurement_analytics": "Procurement Dashboard",

View File

@@ -0,0 +1,47 @@
{
"title": "Premises",
"description": "Manage all your premises from one place",
"stats": {
"total_premises": "Total Premises",
"active_premises": "Active Premises",
"inactive_premises": "Inactive Premises"
},
"search": {
"placeholder": "Search by name or city..."
},
"filters": {
"status": "Status",
"all_statuses": "All statuses",
"active": "Active",
"inactive": "Inactive"
},
"card": {
"location": "Location",
"more_details": "More Details",
"status_active": "Active",
"status_inactive": "Inactive"
},
"detail": {
"back_to_list": "Back to list",
"tabs": {
"procurement": "Procurement",
"production": "Production",
"pos": "Point of Sale",
"suppliers": "Suppliers",
"inventory": "Inventory",
"recipes": "Recipes",
"orders": "Orders",
"machinery": "Machinery",
"quality_templates": "Quality Templates",
"team": "Team",
"ai_models": "AI Models",
"sustainability": "Sustainability",
"settings": "Settings"
}
},
"empty": {
"title": "No premises",
"description": "No premises found matching your search"
},
"loading": "Loading premises..."
}

View File

@@ -11,6 +11,7 @@
"pos": "Punto de Venta",
"distribution": "Distribución",
"central_baker": "Central Baker",
"premises": "Locales",
"analytics": "Análisis",
"production_analytics": "Dashboard de Producción",
"procurement_analytics": "Dashboard de Compras",

View File

@@ -0,0 +1,47 @@
{
"title": "Locales",
"description": "Gestiona todos tus locales desde un solo lugar",
"stats": {
"total_premises": "Total de Locales",
"active_premises": "Locales Activos",
"inactive_premises": "Locales Inactivos"
},
"search": {
"placeholder": "Buscar por nombre o ciudad..."
},
"filters": {
"status": "Estado",
"all_statuses": "Todos los estados",
"active": "Activo",
"inactive": "Inactivo"
},
"card": {
"location": "Ubicación",
"more_details": "Más Detalles",
"status_active": "Activo",
"status_inactive": "Inactivo"
},
"detail": {
"back_to_list": "Volver a la lista",
"tabs": {
"procurement": "Compras",
"production": "Producción",
"pos": "Punto de Venta",
"suppliers": "Proveedores",
"inventory": "Inventario",
"recipes": "Recetas",
"orders": "Pedidos",
"machinery": "Maquinaria",
"quality_templates": "Plantillas de Calidad",
"team": "Trabajadores",
"ai_models": "Modelos IA",
"sustainability": "Sostenibilidad",
"settings": "Ajustes"
}
},
"empty": {
"title": "No hay locales",
"description": "No se encontraron locales que coincidan con tu búsqueda"
},
"loading": "Cargando locales..."
}

View File

@@ -9,6 +9,9 @@
"orders": "Eskaerak",
"procurement": "Erosketak",
"pos": "Salmenta-puntua",
"distribution": "Banaketa",
"central_baker": "Okinleku Zentrala",
"premises": "Lokalak",
"analytics": "Analisiak",
"production_analytics": "Ekoizpen Aginte-panela",
"procurement_analytics": "Erosketa Aginte-panela",

View File

@@ -0,0 +1,47 @@
{
"title": "Lokalak",
"description": "Kudeatu zure lokal guztiak leku batetik",
"stats": {
"total_premises": "Lokal Guztira",
"active_premises": "Lokal Aktiboak",
"inactive_premises": "Lokal Ez-aktiboak"
},
"search": {
"placeholder": "Bilatu izenaz edo hiriaz..."
},
"filters": {
"status": "Egoera",
"all_statuses": "Egoera guztiak",
"active": "Aktiboa",
"inactive": "Ez-aktiboa"
},
"card": {
"location": "Kokalekua",
"more_details": "Xehetasun Gehiago",
"status_active": "Aktiboa",
"status_inactive": "Ez-aktiboa"
},
"detail": {
"back_to_list": "Zerrendara itzuli",
"tabs": {
"procurement": "Erosketak",
"production": "Ekoizpena",
"pos": "Salmenta-puntua",
"suppliers": "Hornitzaileak",
"inventory": "Inbentarioa",
"recipes": "Errezetak",
"orders": "Eskaerak",
"machinery": "Makinaria",
"quality_templates": "Kalitate Txantiloiak",
"team": "Langileak",
"ai_models": "IA Ereduak",
"sustainability": "Jasangarritasuna",
"settings": "Ezarpenak"
}
},
"empty": {
"title": "Ez dago lokalik",
"description": "Ez da zure bilaketarekin bat datorren lokalik aurkitu"
},
"loading": "Lokalak kargatzen..."
}

View File

@@ -26,6 +26,7 @@ import alertsEs from './es/alerts.json';
import onboardingEs from './es/onboarding.json';
import setupWizardEs from './es/setup_wizard.json';
import contactEs from './es/contact.json';
import premisesEs from './es/premises.json';
// English translations
import commonEn from './en/common.json';
@@ -55,6 +56,7 @@ import alertsEn from './en/alerts.json';
import onboardingEn from './en/onboarding.json';
import setupWizardEn from './en/setup_wizard.json';
import contactEn from './en/contact.json';
import premisesEn from './en/premises.json';
// Basque translations
import commonEu from './eu/common.json';
@@ -84,6 +86,7 @@ import alertsEu from './eu/alerts.json';
import onboardingEu from './eu/onboarding.json';
import setupWizardEu from './eu/setup_wizard.json';
import contactEu from './eu/contact.json';
import premisesEu from './eu/premises.json';
// Translation resources by language
export const resources = {
@@ -115,6 +118,7 @@ export const resources = {
onboarding: onboardingEs,
setup_wizard: setupWizardEs,
contact: contactEs,
premises: premisesEs,
},
en: {
common: commonEn,
@@ -144,6 +148,7 @@ export const resources = {
onboarding: onboardingEn,
setup_wizard: setupWizardEn,
contact: contactEn,
premises: premisesEn,
},
eu: {
common: commonEu,
@@ -173,6 +178,7 @@ export const resources = {
onboarding: onboardingEu,
setup_wizard: setupWizardEu,
contact: contactEu,
premises: premisesEu,
},
};
@@ -209,7 +215,7 @@ export const languageConfig = {
};
// Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts', 'onboarding', 'setup_wizard', 'contact'] as const;
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts', 'onboarding', 'setup_wizard', 'contact', 'premises'] as const;
export type Namespace = typeof namespaces[number];
// Helper function to get language display name

View File

@@ -0,0 +1,347 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Building2,
Eye,
ArrowLeft,
CheckCircle,
XCircle,
ShoppingCart,
Factory,
Store,
Users,
Package,
ChefHat,
ClipboardList,
Cog,
ClipboardCheck,
UserCircle,
BrainCircuit,
Leaf,
Settings
} from 'lucide-react';
// UI Components
import {
StatsGrid,
StatusCard,
getStatusColor,
SearchAndFilter,
EmptyState,
type FilterConfig
} from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
// Hooks
import { usePremises, usePremisesStats } from '../../../../api/hooks/usePremises';
import { useTenant } from '../../../../stores/tenant.store';
import type { TenantResponse } from '../../../../api/types/tenant';
// Lazy-loaded page components for tabs
const ProcurementPage = React.lazy(() => import('../../operations/procurement/ProcurementPage'));
const ProductionPage = React.lazy(() => import('../../operations/production/ProductionPage'));
const POSPage = React.lazy(() => import('../../operations/pos/POSPage'));
const SuppliersPage = React.lazy(() => import('../../operations/suppliers/SuppliersPage'));
const InventoryPage = React.lazy(() => import('../../operations/inventory/InventoryPage'));
const RecipesPage = React.lazy(() => import('../../operations/recipes/RecipesPage'));
const OrdersPage = React.lazy(() => import('../../operations/orders/OrdersPage'));
const MaquinariaPage = React.lazy(() => import('../../operations/maquinaria/MaquinariaPage'));
const QualityTemplatesPage = React.lazy(() => import('../../database/quality-templates/QualityTemplatesPage'));
const TeamPage = React.lazy(() => import('../../settings/team/TeamPage'));
const ModelsConfigPage = React.lazy(() => import('../../database/models/ModelsConfigPage'));
const SustainabilityPage = React.lazy(() => import('../../database/sustainability/SustainabilityPage'));
const BakerySettingsPage = React.lazy(() => import('../../settings/bakery/BakerySettingsPage'));
interface SelectedPremise {
id: string;
name: string;
}
type TabKey = 'procurement' | 'production' | 'pos' | 'suppliers' | 'inventory' |
'recipes' | 'orders' | 'machinery' | 'quality_templates' |
'team' | 'ai_models' | 'sustainability' | 'settings';
const PremisesPage: React.FC = () => {
const { t } = useTranslation(['premises', 'common']);
// Tenant store
const {
currentTenant,
parentTenant,
switchToChildTenant,
restoreParentTenant
} = useTenant();
// State
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'active' | 'inactive' | ''>('');
const [selectedPremise, setSelectedPremise] = useState<SelectedPremise | null>(null);
const [activeTab, setActiveTab] = useState<TabKey>('procurement');
// Get parent tenant ID (enterprise tenant)
// If we have a parentTenant stored, use it; otherwise use currentTenant
const enterpriseTenantId = parentTenant?.id || currentTenant?.id || '';
// Hooks
const { data: premises = [], isLoading: isPremisesLoading } = usePremises(
enterpriseTenantId,
{ search: searchTerm, status: statusFilter },
{ enabled: !!enterpriseTenantId && !selectedPremise }
);
const { data: stats, isLoading: isStatsLoading } = usePremisesStats(
enterpriseTenantId,
{ enabled: !!enterpriseTenantId && !selectedPremise }
);
// Handle selecting a premise for detail view
const handleSelectPremise = useCallback(async (premiseId: string, premiseName: string) => {
// Find the child tenant in the premises list
const childTenant = premises.find(p => p.id === premiseId);
if (!childTenant) {
console.error('[PremisesPage] Child tenant not found:', premiseId);
return;
}
console.log('[PremisesPage] Selecting premise:', premiseName, premiseId);
// Switch to child tenant (this automatically stores parent tenant)
const success = await switchToChildTenant(childTenant);
if (!success) {
console.error('[PremisesPage] Failed to switch to child tenant');
return;
}
console.log('[PremisesPage] Tenant switch successful, showing detail view');
// Small delay to ensure API client is updated before rendering child components
await new Promise(resolve => setTimeout(resolve, 50));
// Set selected premise for detail view
setSelectedPremise({ id: premiseId, name: premiseName });
setActiveTab('procurement'); // Reset to first tab
}, [premises, switchToChildTenant]);
// Handle returning to list view
const handleBackToList = useCallback(async () => {
// Restore parent tenant context
await restoreParentTenant();
// Clear selected premise
setSelectedPremise(null);
}, [restoreParentTenant]);
// Filter configuration
const filterConfig: FilterConfig[] = [
{
key: 'status',
type: 'dropdown',
label: t('premises:filters.status'),
value: statusFilter,
onChange: (value) => setStatusFilter(value as 'active' | 'inactive' | ''),
placeholder: t('premises:filters.all_statuses'),
options: [
{ value: 'active', label: t('premises:filters.active') },
{ value: 'inactive', label: t('premises:filters.inactive') }
]
}
];
// Get status indicator config for a premise
const getPremiseStatusConfig = (isActive: boolean) => ({
color: isActive ? getStatusColor('approved') : getStatusColor('cancelled'),
text: isActive ? t('premises:card.status_active') : t('premises:card.status_inactive'),
icon: isActive ? CheckCircle : XCircle,
isCritical: false
});
// Render tab content
const renderTabContent = (tab: TabKey) => {
const TabComponent = {
procurement: ProcurementPage,
production: ProductionPage,
pos: POSPage,
suppliers: SuppliersPage,
inventory: InventoryPage,
recipes: RecipesPage,
orders: OrdersPage,
machinery: MaquinariaPage,
quality_templates: QualityTemplatesPage,
team: TeamPage,
ai_models: ModelsConfigPage,
sustainability: SustainabilityPage,
settings: BakerySettingsPage,
}[tab];
return (
<React.Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin"></div>
</div>
}>
<TabComponent />
</React.Suspense>
);
};
// Detail View
if (selectedPremise) {
const tabs: Array<{ key: TabKey; label: string; icon: React.ElementType }> = [
{ key: 'procurement', label: t('premises:detail.tabs.procurement'), icon: ShoppingCart },
{ key: 'production', label: t('premises:detail.tabs.production'), icon: Factory },
{ key: 'pos', label: t('premises:detail.tabs.pos'), icon: Store },
{ key: 'suppliers', label: t('premises:detail.tabs.suppliers'), icon: Users },
{ key: 'inventory', label: t('premises:detail.tabs.inventory'), icon: Package },
{ key: 'recipes', label: t('premises:detail.tabs.recipes'), icon: ChefHat },
{ key: 'orders', label: t('premises:detail.tabs.orders'), icon: ClipboardList },
{ key: 'machinery', label: t('premises:detail.tabs.machinery'), icon: Cog },
{ key: 'quality_templates', label: t('premises:detail.tabs.quality_templates'), icon: ClipboardCheck },
{ key: 'team', label: t('premises:detail.tabs.team'), icon: UserCircle },
{ key: 'ai_models', label: t('premises:detail.tabs.ai_models'), icon: BrainCircuit },
{ key: 'sustainability', label: t('premises:detail.tabs.sustainability'), icon: Leaf },
{ key: 'settings', label: t('premises:detail.tabs.settings'), icon: Settings }
];
return (
<div className="space-y-6">
{/* Back button and header */}
<div className="flex items-center gap-4">
<button
onClick={handleBackToList}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('premises:detail.back_to_list')}
</button>
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
{selectedPremise.name}
</h1>
</div>
</div>
{/* Tab Navigation */}
<div className="border-b border-[var(--border-primary)] overflow-x-auto">
<div className="flex gap-1 -mb-px min-w-max px-2">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`
flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2
transition-all duration-200 whitespace-nowrap rounded-t-lg
${activeTab === tab.key
? 'border-[var(--color-primary)] text-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] hover:border-[var(--border-secondary)]'
}
`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* Tab Content */}
<div>
{renderTabContent(activeTab)}
</div>
</div>
);
}
// List View
return (
<div className="space-y-6">
{/* Header */}
<PageHeader
title={t('premises:title')}
description={t('premises:description')}
/>
{/* Stats */}
<StatsGrid
columns={3}
stats={[
{
title: t('premises:stats.total_premises'),
value: stats?.total ?? 0,
icon: Building2,
variant: 'info'
},
{
title: t('premises:stats.active_premises'),
value: stats?.active ?? 0,
icon: CheckCircle,
variant: 'success'
},
{
title: t('premises:stats.inactive_premises'),
value: stats?.inactive ?? 0,
icon: XCircle,
variant: 'warning'
}
]}
/>
{/* Search and Filters */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder={t('premises:search.placeholder')}
filters={filterConfig}
/>
{/* Premises Grid */}
{isPremisesLoading || isStatsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin"></div>
<p className="text-sm text-[var(--text-secondary)]">{t('premises:loading')}</p>
</div>
</div>
) : premises.length === 0 ? (
<EmptyState
icon={Building2}
title={t('premises:empty.title')}
description={t('premises:empty.description')}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{premises.map((premise: TenantResponse) => (
<StatusCard
key={premise.id}
id={premise.id}
title={premise.name}
subtitle={premise.address || premise.city}
statusIndicator={getPremiseStatusConfig(premise.is_active)}
primaryValue={premise.city || '-'}
primaryValueLabel={t('premises:card.location')}
metadata={[
premise.business_type ? `${premise.business_type}` : '',
premise.phone || ''
].filter(Boolean)}
actions={[
{
label: t('premises:card.more_details'),
icon: Eye,
onClick: () => handleSelectPremise(premise.id, premise.name),
priority: 'primary'
}
]}
onClick={() => handleSelectPremise(premise.id, premise.name)}
/>
))}
</div>
)}
</div>
);
};
export default PremisesPage;

View File

@@ -0,0 +1 @@
export { default } from './PremisesPage';

View File

@@ -36,6 +36,9 @@ const POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage'));
const MaquinariaPage = React.lazy(() => import('../pages/app/operations/maquinaria/MaquinariaPage'));
const DistributionPage = React.lazy(() => import('../pages/app/operations/distribution/DistributionPage'));
// Enterprise pages
const PremisesPage = React.lazy(() => import('../pages/app/enterprise/premises/PremisesPage'));
// Analytics pages
const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProductionAnalyticsPage'));
const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage'));
@@ -118,6 +121,18 @@ export const AppRouter: React.FC = () => {
}
/>
{/* Enterprise Routes - Multi-tenant management */}
<Route
path="/app/enterprise/premises"
element={
<ProtectedRoute>
<AppShell>
<PremisesPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Operations Routes - Business Operations Only */}
<Route
path="/app/operations/procurement"

View File

@@ -11,7 +11,7 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
import { useCurrentTenant, useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
import { useHasAccess, useIsDemoMode } from '../hooks/useAccessControl';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
import { checkCombinedPermission, type User, type TenantAccess } from '../utils/permissions';
@@ -135,6 +135,7 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const isLoading = useAuthLoading();
const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess();
const { hasPermission } = useTenantPermissions();
const location = useLocation();

View File

@@ -111,6 +111,9 @@ export const ROUTES = {
POS_WEBHOOKS: '/pos/webhooks',
POS_SETTINGS: '/pos/settings',
// Enterprise
PREMISES: '/app/enterprise/premises',
// Analytics
ANALYTICS_EVENTS: '/app/analytics/events',
@@ -248,6 +251,22 @@ export const routesConfig: RouteConfig[] = [
},
},
// Enterprise Section - Multi-tenant management
{
path: ROUTES.PREMISES,
name: 'Premises',
component: 'PremisesPage',
title: 'Locales',
icon: 'premises',
requiresAuth: true,
requiredSubscriptionFeature: 'multi_tenant_management',
showInNavigation: true,
meta: {
headerTitle: 'Locales',
preload: false,
},
},
// Operations Section - Business Operations Only
{
path: '/app/operations',

View File

@@ -7,21 +7,25 @@ import { TENANT_ROLES, GLOBAL_USER_ROLES } from '../types/roles';
export interface TenantState {
// State
currentTenant: TenantResponse | null;
parentTenant: TenantResponse | null; // Enterprise parent tenant for child premise navigation
availableTenants: TenantResponse[] | null;
currentTenantAccess: TenantAccessResponse | null;
isLoading: boolean;
error: string | null;
// Actions
setCurrentTenant: (tenant: TenantResponse) => void;
setAvailableTenants: (tenants: TenantResponse[]) => void;
switchTenant: (tenantId: string) => Promise<boolean>;
switchToChildTenant: (childTenant: TenantResponse) => Promise<boolean>; // Switch to child while remembering parent
restoreParentTenant: () => Promise<boolean>; // Restore parent tenant context
loadUserTenants: () => Promise<void>;
loadChildTenants: () => Promise<void>; // Load child tenants for enterprise users
loadCurrentTenantAccess: () => Promise<void>;
clearTenants: () => void;
clearError: () => void;
setLoading: (loading: boolean) => void;
// Permission helpers (migrated from BakeryContext)
hasPermission: (permission: string) => boolean;
canAccess: (resource: string, action: string) => boolean;
@@ -32,6 +36,7 @@ export const useTenantStore = create<TenantState>()(
(set, get) => ({
// Initial state
currentTenant: null,
parentTenant: null,
availableTenants: null,
currentTenantAccess: null,
isLoading: false,
@@ -55,15 +60,15 @@ export const useTenantStore = create<TenantState>()(
switchTenant: async (tenantId: string): Promise<boolean> => {
try {
set({ isLoading: true, error: null });
const { availableTenants } = get();
// Find tenant in available tenants
const targetTenant = availableTenants?.find(t => t.id === tenantId);
if (!targetTenant) {
throw new Error('Tenant not found in available tenants');
}
// Switch tenant (frontend-only operation)
get().setCurrentTenant(targetTenant);
set({ isLoading: false });
@@ -77,6 +82,111 @@ export const useTenantStore = create<TenantState>()(
}
},
switchToChildTenant: async (childTenant: TenantResponse): Promise<boolean> => {
try {
set({ isLoading: true, error: null });
const { currentTenant, availableTenants } = get();
console.log('[Tenant Store] Switching to child tenant:', {
from: currentTenant?.id,
fromName: currentTenant?.name,
to: childTenant.id,
toName: childTenant.name
});
// Store current tenant as parent (for enterprise users navigating to child premises)
if (currentTenant) {
set({ parentTenant: currentTenant });
console.log('[Tenant Store] Stored parent tenant:', currentTenant.id);
}
// Add child to availableTenants if not already present
if (availableTenants && !availableTenants.find(t => t.id === childTenant.id)) {
set({ availableTenants: [...availableTenants, childTenant] });
}
// CRITICAL: Directly update API client BEFORE updating state
console.log('[Tenant Store] Directly updating API client with child tenant ID:', childTenant.id);
const { apiClient } = await import('../api/client');
apiClient.setTenantId(childTenant.id);
// Verify the API client was updated
const verifiedTenantId = apiClient.getTenantId();
console.log('[Tenant Store] Verified API client tenant ID:', verifiedTenantId);
if (verifiedTenantId !== childTenant.id) {
console.error('[Tenant Store] API client tenant ID mismatch! Expected:', childTenant.id, 'Got:', verifiedTenantId);
set({ isLoading: false, error: 'Failed to update API client tenant ID' });
return false;
}
// Now update the store state
// IMPORTANT: We've already updated the API client above, so we just update the state
// We DON'T call setCurrentTenant action because that would trigger tenantService.setCurrentTenant
// which would call apiClient.setTenantId again
set({ currentTenant: childTenant, currentTenantAccess: null });
console.log('[Tenant Store] Switch complete. Current tenant is now:', get().currentTenant?.id);
console.log('[Tenant Store] Final API client tenant ID verification:', apiClient.getTenantId());
set({ isLoading: false });
return true;
} catch (error) {
console.error('[Tenant Store] Failed to switch to child tenant:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to switch to child tenant',
});
return false;
}
},
restoreParentTenant: async (): Promise<boolean> => {
try {
const { parentTenant } = get();
if (!parentTenant) {
console.warn('[Tenant Store] No parent tenant to restore');
return false;
}
set({ isLoading: true, error: null });
console.log('[Tenant Store] Restoring parent tenant:', parentTenant.id, parentTenant.name);
// CRITICAL: Directly update API client BEFORE updating state
const { apiClient } = await import('../api/client');
apiClient.setTenantId(parentTenant.id);
// Verify the API client was updated
const verifiedTenantId = apiClient.getTenantId();
console.log('[Tenant Store] Verified API client tenant ID after restore:', verifiedTenantId);
if (verifiedTenantId !== parentTenant.id) {
console.error('[Tenant Store] API client tenant ID mismatch! Expected:', parentTenant.id, 'Got:', verifiedTenantId);
set({ isLoading: false, error: 'Failed to update API client tenant ID' });
return false;
}
// Now update the store state
set({ currentTenant: parentTenant, currentTenantAccess: null });
// Clear parent tenant reference
set({ parentTenant: null, isLoading: false });
console.log('[Tenant Store] Parent tenant restored successfully');
return true;
} catch (error) {
console.error('[Tenant Store] Failed to restore parent tenant:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to restore parent tenant',
});
return false;
}
},
loadUserTenants: async (): Promise<void> => {
try {
set({ isLoading: true, error: null });
@@ -129,6 +239,35 @@ export const useTenantStore = create<TenantState>()(
}
},
loadChildTenants: async (): Promise<void> => {
try {
const { currentTenant, availableTenants } = get();
if (!currentTenant) {
console.warn('No current tenant to load children for');
return;
}
// Fetch child tenants
const children = await tenantService.getChildTenants(currentTenant.id);
if (children && children.length > 0) {
// Add child tenants to availableTenants if not already present
const currentAvailable = availableTenants || [];
const newTenants = children.filter(
child => !currentAvailable.find(t => t.id === child.id)
);
if (newTenants.length > 0) {
set({ availableTenants: [...currentAvailable, ...newTenants] });
}
}
} catch (error) {
console.warn('Failed to load child tenants:', error);
// Don't set error state - this is optional enhancement
}
},
loadCurrentTenantAccess: async (): Promise<void> => {
try {
const { currentTenant } = get();
@@ -146,6 +285,7 @@ export const useTenantStore = create<TenantState>()(
clearTenants: () => {
set({
currentTenant: null,
parentTenant: null,
availableTenants: null,
currentTenantAccess: null,
error: null,
@@ -213,6 +353,7 @@ export const useTenantStore = create<TenantState>()(
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
currentTenant: state.currentTenant,
parentTenant: state.parentTenant,
availableTenants: state.availableTenants,
currentTenantAccess: state.currentTenantAccess,
}),
@@ -231,6 +372,7 @@ export const useTenantStore = create<TenantState>()(
// Selectors for common use cases
// Note: For getting tenant ID, prefer using useTenantId() from hooks/useTenantId.ts
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
export const useParentTenant = () => useTenantStore((state) => state.parentTenant);
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess);
export const useTenantLoading = () => useTenantStore((state) => state.isLoading);
@@ -241,7 +383,10 @@ export const useTenantActions = () => useTenantStore((state) => ({
setCurrentTenant: state.setCurrentTenant,
setAvailableTenants: state.setAvailableTenants,
switchTenant: state.switchTenant,
switchToChildTenant: state.switchToChildTenant,
restoreParentTenant: state.restoreParentTenant,
loadUserTenants: state.loadUserTenants,
loadChildTenants: state.loadChildTenants,
loadCurrentTenantAccess: state.loadCurrentTenantAccess,
clearTenants: state.clearTenants,
clearError: state.clearError,
@@ -257,15 +402,17 @@ export const useTenantPermissions = () => useTenantStore((state) => ({
// Combined hook for convenience
export const useTenant = () => {
const currentTenant = useCurrentTenant();
const parentTenant = useParentTenant();
const availableTenants = useAvailableTenants();
const currentTenantAccess = useCurrentTenantAccess();
const isLoading = useTenantLoading();
const error = useTenantError();
const actions = useTenantActions();
const permissions = useTenantPermissions();
return {
currentTenant,
parentTenant,
availableTenants,
currentTenantAccess,
isLoading,

View File

@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useIsAuthenticated } from './auth.store';
import { useTenantActions, useAvailableTenants, useCurrentTenant } from './tenant.store';
import { useTenantActions, useAvailableTenants, useCurrentTenant, useParentTenant } from './tenant.store';
import { useIsDemoMode, useDemoSessionId, useDemoAccountType } from '../hooks/useAccessControl';
import { useSubscription } from '../api/hooks/subscription';
import { SUBSCRIPTION_TIERS, SubscriptionTier } from '../api/types/subscription';
/**
@@ -54,7 +55,9 @@ export const useTenantInitializer = () => {
const demoAccountType = useDemoAccountType();
const availableTenants = useAvailableTenants();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant, setAvailableTenants } = useTenantActions();
const parentTenant = useParentTenant(); // Track if we're viewing a child tenant
const { loadUserTenants, loadChildTenants, setCurrentTenant, setAvailableTenants } = useTenantActions();
const { subscriptionInfo } = useSubscription();
// Load tenants for authenticated users (but not demo users - they have special initialization below)
useEffect(() => {
@@ -63,6 +66,25 @@ export const useTenantInitializer = () => {
}
}, [isAuthenticated, availableTenants, loadUserTenants, isDemoMode]);
// Load child tenants for enterprise users
useEffect(() => {
if (
isAuthenticated &&
!isDemoMode &&
currentTenant &&
availableTenants &&
subscriptionInfo?.plan === 'enterprise'
) {
// Only load if we haven't loaded child tenants yet
// Check if availableTenants only contains the parent (length === 1)
const hasOnlyParent = availableTenants.length === 1 && availableTenants[0].id === currentTenant.id;
if (hasOnlyParent) {
loadChildTenants();
}
}
}, [isAuthenticated, isDemoMode, currentTenant, availableTenants, subscriptionInfo, loadChildTenants]);
// Set up mock tenant for demo mode with appropriate subscription tier
useEffect(() => {
if (isDemoMode && demoSessionId) {
@@ -106,8 +128,10 @@ export const useTenantInitializer = () => {
created_at: new Date().toISOString(),
};
// Only set current tenant if not already valid
if (!isValidDemoTenant) {
// Only set current tenant if not already valid AND we're not viewing a child tenant
// CRITICAL: If parentTenant exists, it means we're viewing a child tenant from the Premises page
// and we should NOT overwrite the current tenant
if (!isValidDemoTenant && !parentTenant) {
// Set the demo tenant as current
setCurrentTenant(mockTenant);
@@ -155,5 +179,5 @@ export const useTenantInitializer = () => {
});
}
}
}, [isDemoMode, demoSessionId, demoAccountType, currentTenant, availableTenants, setCurrentTenant, setAvailableTenants]);
}, [isDemoMode, demoSessionId, demoAccountType, currentTenant, availableTenants, parentTenant, setCurrentTenant, setAvailableTenants]);
};