From 560c7ba86fc61f0b02b8ae8b127a986533c54762 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Wed, 7 Jan 2026 16:01:19 +0100 Subject: [PATCH] Improve enterprise tier child tenants access --- frontend/src/api/client/apiClient.ts | 4 + frontend/src/api/hooks/usePremises.ts | 79 ++++ .../components/layout/AppShell/AppShell.tsx | 41 ++- .../src/components/layout/Sidebar/Sidebar.tsx | 5 +- frontend/src/components/ui/TenantSwitcher.tsx | 8 + frontend/src/locales/en/common.json | 1 + frontend/src/locales/en/premises.json | 47 +++ frontend/src/locales/es/common.json | 1 + frontend/src/locales/es/premises.json | 47 +++ frontend/src/locales/eu/common.json | 3 + frontend/src/locales/eu/premises.json | 47 +++ frontend/src/locales/index.ts | 8 +- .../app/enterprise/premises/PremisesPage.tsx | 347 ++++++++++++++++++ .../pages/app/enterprise/premises/index.ts | 1 + frontend/src/router/AppRouter.tsx | 15 + frontend/src/router/ProtectedRoute.tsx | 3 +- frontend/src/router/routes.config.ts | 19 + frontend/src/stores/tenant.store.ts | 159 +++++++- frontend/src/stores/useTenantInitializer.ts | 34 +- 19 files changed, 854 insertions(+), 15 deletions(-) create mode 100644 frontend/src/api/hooks/usePremises.ts create mode 100644 frontend/src/locales/en/premises.json create mode 100644 frontend/src/locales/es/premises.json create mode 100644 frontend/src/locales/eu/premises.json create mode 100644 frontend/src/pages/app/enterprise/premises/PremisesPage.tsx create mode 100644 frontend/src/pages/app/enterprise/premises/index.ts diff --git a/frontend/src/api/client/apiClient.ts b/frontend/src/api/client/apiClient.ts index 0942c796..9529ac15 100644 --- a/frontend/src/api/client/apiClient.ts +++ b/frontend/src/api/client/apiClient.ts @@ -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) { diff --git a/frontend/src/api/hooks/usePremises.ts b/frontend/src/api/hooks/usePremises.ts new file mode 100644 index 00000000..a6e82c6a --- /dev/null +++ b/frontend/src/api/hooks/usePremises.ts @@ -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 => { + 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 => { + 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, + }); +}; diff --git a/frontend/src/components/layout/AppShell/AppShell.tsx b/frontend/src/components/layout/AppShell/AppShell.tsx index 709ac898..552a737e 100644 --- a/frontend/src/components/layout/AppShell/AppShell.tsx +++ b/frontend/src/components/layout/AppShell/AppShell.tsx @@ -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(({ 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(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(({ ); } + // 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 ( +
+ {loadingComponent || ( +
+
+

Restaurando contexto...

+
+ )} +
+ ); + } + // Show error state if (error && ErrorBoundary) { return {children}; diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index a97f5ff9..2b74a16c 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -50,7 +50,8 @@ import { Layers, Lightbulb, Activity, - List + List, + Building } from 'lucide-react'; export interface SidebarProps { @@ -138,6 +139,7 @@ const iconMap: Record> = { events: Activity, list: List, distribution: Truck, + premises: Building, }; /** @@ -196,6 +198,7 @@ export const Sidebar = forwardRef(({ const pathMappings: Record = { '/app/dashboard': 'navigation.dashboard', + '/app/enterprise/premises': 'navigation.premises', '/app/operations': 'navigation.operations', '/app/operations/procurement': 'navigation.procurement', '/app/operations/production': 'navigation.production', diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx index 15aad212..5a1b2981 100644 --- a/frontend/src/components/ui/TenantSwitcher.tsx +++ b/frontend/src/components/ui/TenantSwitcher.tsx @@ -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 = ({ 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 = ({ 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; diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 6de6f1b5..f06fe0ff 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -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", diff --git a/frontend/src/locales/en/premises.json b/frontend/src/locales/en/premises.json new file mode 100644 index 00000000..da109ae5 --- /dev/null +++ b/frontend/src/locales/en/premises.json @@ -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..." +} diff --git a/frontend/src/locales/es/common.json b/frontend/src/locales/es/common.json index c793d1cb..fe2ef866 100644 --- a/frontend/src/locales/es/common.json +++ b/frontend/src/locales/es/common.json @@ -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", diff --git a/frontend/src/locales/es/premises.json b/frontend/src/locales/es/premises.json new file mode 100644 index 00000000..3812116d --- /dev/null +++ b/frontend/src/locales/es/premises.json @@ -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..." +} diff --git a/frontend/src/locales/eu/common.json b/frontend/src/locales/eu/common.json index daccbe20..48dbb700 100644 --- a/frontend/src/locales/eu/common.json +++ b/frontend/src/locales/eu/common.json @@ -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", diff --git a/frontend/src/locales/eu/premises.json b/frontend/src/locales/eu/premises.json new file mode 100644 index 00000000..8bb17b89 --- /dev/null +++ b/frontend/src/locales/eu/premises.json @@ -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..." +} diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index 4a408852..6e6397b6 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -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 diff --git a/frontend/src/pages/app/enterprise/premises/PremisesPage.tsx b/frontend/src/pages/app/enterprise/premises/PremisesPage.tsx new file mode 100644 index 00000000..363b7ab4 --- /dev/null +++ b/frontend/src/pages/app/enterprise/premises/PremisesPage.tsx @@ -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(null); + const [activeTab, setActiveTab] = useState('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 ( + +
+ + }> + +
+ ); + }; + + // 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 ( +
+ {/* Back button and header */} +
+ +
+

+ {selectedPremise.name} +

+
+
+ + {/* Tab Navigation */} +
+
+ {tabs.map(tab => { + const Icon = tab.icon; + return ( + + ); + })} +
+
+ + {/* Tab Content */} +
+ {renderTabContent(activeTab)} +
+
+ ); + } + + // List View + return ( +
+ {/* Header */} + + + {/* Stats */} + + + {/* Search and Filters */} + + + {/* Premises Grid */} + {isPremisesLoading || isStatsLoading ? ( +
+
+
+

{t('premises:loading')}

+
+
+ ) : premises.length === 0 ? ( + + ) : ( +
+ {premises.map((premise: TenantResponse) => ( + handleSelectPremise(premise.id, premise.name), + priority: 'primary' + } + ]} + onClick={() => handleSelectPremise(premise.id, premise.name)} + /> + ))} +
+ )} +
+ ); +}; + +export default PremisesPage; diff --git a/frontend/src/pages/app/enterprise/premises/index.ts b/frontend/src/pages/app/enterprise/premises/index.ts new file mode 100644 index 00000000..2ba54002 --- /dev/null +++ b/frontend/src/pages/app/enterprise/premises/index.ts @@ -0,0 +1 @@ +export { default } from './PremisesPage'; diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 2e06a9f8..4fd284ab 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -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 */} + + + + + + } + /> + {/* Operations Routes - Business Operations Only */} = ({ const user = useAuthUser(); const isAuthenticated = useIsAuthenticated(); const isLoading = useAuthLoading(); + const currentTenant = useCurrentTenant(); const currentTenantAccess = useCurrentTenantAccess(); const { hasPermission } = useTenantPermissions(); const location = useLocation(); diff --git a/frontend/src/router/routes.config.ts b/frontend/src/router/routes.config.ts index c4748ce3..cf9cb7c7 100644 --- a/frontend/src/router/routes.config.ts +++ b/frontend/src/router/routes.config.ts @@ -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', diff --git a/frontend/src/stores/tenant.store.ts b/frontend/src/stores/tenant.store.ts index 2b48e021..a83a64d6 100644 --- a/frontend/src/stores/tenant.store.ts +++ b/frontend/src/stores/tenant.store.ts @@ -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; + switchToChildTenant: (childTenant: TenantResponse) => Promise; // Switch to child while remembering parent + restoreParentTenant: () => Promise; // Restore parent tenant context loadUserTenants: () => Promise; + loadChildTenants: () => Promise; // Load child tenants for enterprise users loadCurrentTenantAccess: () => Promise; 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()( (set, get) => ({ // Initial state currentTenant: null, + parentTenant: null, availableTenants: null, currentTenantAccess: null, isLoading: false, @@ -55,15 +60,15 @@ export const useTenantStore = create()( switchTenant: async (tenantId: string): Promise => { 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()( } }, + switchToChildTenant: async (childTenant: TenantResponse): Promise => { + 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 => { + 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 => { try { set({ isLoading: true, error: null }); @@ -129,6 +239,35 @@ export const useTenantStore = create()( } }, + loadChildTenants: async (): Promise => { + 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 => { try { const { currentTenant } = get(); @@ -146,6 +285,7 @@ export const useTenantStore = create()( clearTenants: () => { set({ currentTenant: null, + parentTenant: null, availableTenants: null, currentTenantAccess: null, error: null, @@ -213,6 +353,7 @@ export const useTenantStore = create()( 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()( // 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, diff --git a/frontend/src/stores/useTenantInitializer.ts b/frontend/src/stores/useTenantInitializer.ts index b6a2e3e4..b6824ee4 100644 --- a/frontend/src/stores/useTenantInitializer.ts +++ b/frontend/src/stores/useTenantInitializer.ts @@ -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]); }; \ No newline at end of file